Язык программирования Rust
От Стива Клабника и Кэрол Николс, при поддержке других участников сообщества Rust
В этой версии учебника предполагается, что вы используете Rust 1.67.1 (выпущен 09.02.2023) или новее. См. раздел «Установка» главы 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 Book, в которой есть: контрольные вопросы, цветовое выделение, наглядные визуализации и многое другое: https://rust-book.cs.brown.edu
Предисловие
Не всегда было ясно, но язык программирования Rust в основном посвящён расширению возможностей: независимо от того, какой код вы пишете сейчас, Rust позволяет вам достичь большего, чтобы программировать уверенно в более широком диапазоне областей, чем вы делали раньше.
Возьмём, к примеру, работу «системного уровня», которая касается низкоуровневых деталей управления памятью, представления данных и многопоточности. Традиционно эта область программирования считается загадочной, доступной лишь немногим избранным, посвятившим долгие годы изучению всех её печально известных подводных камней. И даже те, кто практикуют это, делают всё с осторожностью, чтобы их код не был уязвим для эксплойтов, сбоев или повреждений.
Rust разрушает эти барьеры, устраняя старые подводные камни и предоставляя дружелюбный, отполированный набор инструментов, который поможет вам на этом пути. Программисты, которым необходимо «погрузиться» в низкоуровневое управление, могут сделать это с помощью Rust, не беря на себя привычный риск аварий или дыр в безопасности и не изучая тонкости изменчивых наборов инструментов. Более того, язык предназначен для того, чтобы легко вести вас к надёжному коду, который эффективен с точки зрения скорости и использования памяти.
Программисты, которые уже работают с низкоуровневым кодом, могут использовать Rust для повышения своих амбиций. Например, внедрение параллелизма в Rust является операцией с относительно низким риском: компилятор поймает для вас классические ошибки. И вы можете заняться более агрессивной оптимизацией в своём коде с уверенностью, что не будете случайно добавлять в код сбои или уязвимости.
Но Rust не ограничивается низкоуровневым системным программированием. Он достаточно выразителен и эргономичен, чтобы приложения CLI (Command Line Interface – консольные программы), веб-серверы и многие другие виды кода были довольно приятными для написания — позже вы найдёте простые примеры того и другого в книге. Работа с Rust позволяет вырабатывать навыки, которые переносятся из одной предметной области в другую; вы можете изучить Rust, написав веб-приложение, а затем применить те же навыки для Raspberry Pi.
Эта книга полностью раскрывает потенциал Rust для расширения возможностей его пользователей. Это дружелюбный и доступный материал, призванный помочь вам повысить уровень не только ваших знаний о Rust, но и ваших возможностей и уверенности как программиста в целом. Так что погружайтесь, готовьтесь учиться и добро пожаловать в сообщество Rust!
— Nicholas Matsakis и Aaron Turon
Введение
Примечание. Это издание книги такое же, как и Язык программирования Rust, доступное в печатном и электронном формате от No Starch Press.
Добро пожаловать в The Rust Programming Language, вводную книгу о Rust. Язык программирования Rust помогает создавать быстрые, более надёжные приложения. Хорошая эргономика и низкоуровневый контроль часто являются противоречивыми требованиями для дизайна языков программирования; Rust бросает вызов этому конфликту. Благодаря сбалансированности мощных технических возможностей c большим удобством разработки, Rust предоставляет возможности управления низкоуровневыми элементами (например, использование памяти) без трудностей, традиционно связанных с таким контролем.
Кому подходит Rust
Rust идеально подходит для многих людей по целому ряду причин. Давайте рассмотрим несколько наиболее важных групп.
Команды разработчиков
Rust зарекомендовал себя как продуктивный инструмент для совместной работы больших команд разработчиков с разным уровнем знаний в области системного программирования. Низкоуровневый код подвержен различным трудноуловимым ошибкам, которые в большинстве других языков могут быть обнаружены только с помощью тщательного тестирования и проверки кода опытными разработчиками. В Rust компилятор играет роль привратника, отказываясь компилировать код с этими неуловимыми ошибками, включая ошибки параллелизма. Работая вместе с компилятором, команда может сфокусироваться на работе над логикой программы, а не над поиском ошибок.
Rust также привносит современные инструменты разработчика в мир системного программирования:
- Cargo, входящий в комплект менеджер зависимостей и инструмент сборки, делает добавление, компиляцию и управление зависимостями безболезненным и согласованным в рамках всей экосистемы Rust.
- Инструмент форматирования Rustfmt обеспечивает единый стиль кодирования для всех разработчиков.
- Rust Language Server обеспечивает интеграцию с интегрированной средой разработки (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, менеджер пакетов и инструмент сборки Rust. Глава 2 - это практическое введение в написание программы на Rust, в которой вам предлагается создать игру для угадывания чисел. Здесь мы рассмотрим концепции на высоком уровне, а в последующих главах будет предоставлена дополнительная информация. Если вы хотите сразу же приступить к работе, глава 2 - самое подходящее место для этого. В главе 3 рассматриваются возможности Rust, схожие с возможностями других языков программирования, а в главе 4 вы узнаете о системе владения Rust. Если вы особенно дотошный ученик и предпочитаете изучить каждую деталь, прежде чем переходить к следующей, возможно, вы захотите пропустить главу 2 и сразу перейти к главе 3, вернувшись к главе 2, когда захотите поработать над проектом, применяя изученные детали.
Глава 5 описывает структуры и методы, а глава 6 охватывает перечисления, выражения match
и конструкции управления потоком if let
. Вы будете использовать структуры и перечисления для создания пользовательских типов в Rust.
В главе 7 вы узнаете о системе модулей Rust, о правилах организации приватности вашего кода и его публичном интерфейсе прикладного программирования (API). В главе 8 обсуждаются некоторые распространённые структуры данных - коллекции, которые предоставляет стандартная библиотека, такие как векторы, строки и HashMaps. В главе 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. В приложении F вы найдёте переводы книги, а в приложении G мы расскажем о том, как создаётся Rust и что такое nightly Rust.
Нет неправильного способа читать эту книгу: если вы хотите пропустить главу - сделайте это! Возможно, вам придётся вернуться к предыдущим главам, если возникнет недопонимание. Делайте все, как вам удобно.
Важной частью процесса обучения Rust является изучение того, как читать сообщения об ошибках, которые отображает компилятор: они приведут вас к работающему коду. Мы изучим много примеров, которые не компилируются и отображают ошибки в сообщениях компилятора в разных ситуациях. Знайте, что если вы введёте и запустите случайный пример, он может не скомпилироваться! Убедитесь, что вы прочитали окружающий текст, чтобы понять, не предназначен ли пример, который вы пытаетесь запустить, для демонстрации ошибки. Ferris также поможет вам различить код, который не предназначен для работы:
Ferris | Пояснения |
---|---|
Этот код не компилируется! | |
Этот код вызывает панику! | |
Этот код не приводит к желаемому поведению. |
В большинстве случаев мы приведём вас к правильной версии любого кода, который не компилируется.
Исходные коды
Файлы с исходным кодом, используемым в этой книге, можно найти на 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.2 https://sh.rustup.rs -sSf | sh
Команда загружает сценарий и запускает установку инструмента rustup
, который устанавливает последнюю стабильную версию Rust. Вам может быть предложено ввести пароль. Если установка прошла успешно, появится следующая строка:
Rust is installed now. Great!
Вам также понадобится компоновщик (linker) — программа, которую 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)
Если вы видите эту информацию, вы успешно установили Rust! Если вы не видите эту информацию, убедитесь, что Rust находится в вашей системной переменной %PATH%
следующим образом:
В Windows CMD:
> echo %PATH%
В PowerShell:
> echo $env:Path
В Linux и macOS:
$ echo $PATH
Если все было сделано правильно, но Rust все ещё не работает, есть несколько мест, где вам могут помочь. Узнайте, как связаться с другими Rustaceans (так мы себя называем) на странице сообщества.
Обновление и удаление
После установки Rust с помощью rustup
обновление до новой версии не составит труда. В командной оболочке запустите следующий скрипт обновления:
$ rustup update
Чтобы удалить Rust и rustup
, выполните следующую команду:
$ rustup self uninstall
Локальная документация
Установка Rust также включает локальную копию документации, чтобы вы могли читать её в автономном режиме. Выполните rustup doc
, чтобы открыть локальную документацию в браузере.
Если стандартная библиотека предоставляет тип или функцию, а вы не знаете, что она делает или как её использовать, воспользуйтесь документацией интерфейса прикладного программирования (API), чтобы это узнать!
Привет, мир!
Теперь, когда вы установили Rust, пришло время написать свою первую программу на Rust. Традиционно при изучении нового языка принято писать небольшую программу, которая печатает на экране текст Привет, мир!
, поэтому мы сделаем то же самое!
Примечание: Эта книга предполагает наличие базового навыка работы с командной строкой. Rust не предъявляет особых требований к тому, каким инструментарием вы пользуетесь для редактирования или хранения вашего кода, поэтому если вы предпочитаете использовать интегрированную среду разработки (IDE) вместо командной строки, смело используйте вашу любимую IDE. Многие IDE сейчас в той или иной степени поддерживают Rust; подробности можно узнать из документации к IDE. Команда Rust сосредоточилась на обеспечении отличной поддержки IDE с помощью
rust-analyzer
. Более подробную информацию смотрите в Приложении D.
Создание папки проекта
Прежде всего начнём с создания директории, в которой будем сохранять наш код на языке Rust. На самом деле не важно, где сохранять наш код. Однако, для упражнений и проектов, обсуждаемых в данной книге, мы советуем создать директорию projects в вашем домашнем каталоге, там же и хранить в будущем код программ из книги.
Откройте терминал и введите следующие команды для того, чтобы создать директорию projects для хранения кода разных проектов, и, внутри неё, директорию 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!("Привет, мир!"); }
Сохраните файл и вернитесь в окно терминала в каталог ~/projects/hello_world. В Linux или macOS введите следующие команды для компиляции и запуска файла:
$ rustc main.rs
$ ./main
Привет, мир!
В Windows, введите команду .\main.exe
вместо ./main
:
> rustc main.rs
> .\main.exe
Привет, мир!
Независимо от вашей операционной системы, строка Привет, мир!
должна быть выведена на терминал. Если вы не видите такого вывода, обратитесь к разделу "Устранение неполадок", чтобы узнать, как получить помощь.
Если напечаталось Привет, мир!
, то примите наши поздравления! Вы написали программу на Rust, что делает вас Rust программистом — добро пожаловать!
Анатомия программы на Rust
Давайте рассмотрим «Привет, мир!» программу в деталях. Вот первая часть головоломки:
fn main() { }
Эти строки определяют функцию с именем main
. Функция main
особенная: это всегда первый код, который запускается в каждой исполняемой программе Rust. Первая строка объявляет функцию с именем main
, которая не имеет параметров и ничего не возвращает. Если бы были параметры, они бы заключались в круглые скобки ()
.
Тело функции заключено в {}
. Rust требует фигурных скобок вокруг всех тел функций. Хороший стиль — поместить открывающую фигурную скобку на ту же строку, что и объявление функции, добавив между ними один пробел.
Примечание: Если хотите придерживаться стандартного стиля во всех проектах Rust, вы можете использовать инструмент автоматического форматирования под названием
rustfmt
для форматирования кода в определённом стиле (подробнее оrustfmt
в Приложении D. Команда Rust включила этот инструмент в стандартный дистрибутив Rust, какrustc
, поэтому он уже должен быть установлен на вашем компьютере!
Тело функции main
содержит следующий код:
#![allow(unused)] fn main() { println!("Привет, мир!"); }
Эта строка делает всю работу в этой маленькой программе: печатает текст на экран. Можно заметить четыре важных детали.
Во-первых, стиль Rust предполагает отступ в четыре пробела, а не табуляцию.
Во-вторых, println!
вызывается макрос Rust. Если бы вместо него была вызвана функция, она была бы набрана как println
(без !
). Более подробно мы обсудим макросы Rust в главе 19. Пока достаточно знать, что использование !
подразумевает вызов макроса вместо обычной функции, и что макросы не всегда подчиняются тем же правилам как функции.
В-третьих, вы видите строку "Привет, мир!"
. Мы передаём её в качестве аргумента макросу println!
, и она выводится на экран.
В-четвёртых, мы завершаем строку точкой с запятой (;
), которая указывает на окончание этого выражения и возможность начала следующего. Большинство строк кода Rust заканчиваются точкой с запятой.
Компиляция и запуск - это отдельные шаги
Вы только что запустили впервые созданную программу, поэтому давайте рассмотрим каждый шаг этого процесса.
Перед запуском программы на Rust вы должны скомпилировать её с помощью компилятора Rust, введя команду rustc
и передав ей имя вашего исходного файла, например:
$ rustc main.rs
Если у вас есть опыт работы с C или C++, вы заметите, что это похоже на gcc
или clang
. После успешной компиляции Rust выводит двоичный исполняемый файл.
В Linux, macOS и PowerShell в Windows вы можете увидеть исполняемый файл, введя команду ls
в оболочке:
$ ls
main main.rs
В Linux и macOS вы увидите два файла. При использовании PowerShell в Windows вы увидите такие же три файла, как и при использовании CMD. Используя 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 — это ваша программа «Привет, мир!», эта строка выведет в терминал Привет, мир!
.
Если вы лучше знакомы с динамическими языками, такими как 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, добавлять зависимости станет намного проще.
Поскольку значительное число проектов Rust используют Cargo, оставшаяся часть книги подразумевает, что вы тоже используете Cargo. Cargo входит в комплект поставки 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"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
Это файл в формате 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), а не в вашем текущем каталоге. Поскольку стандартная сборка является отладочной, Cargo помещает двоичный файл в каталог с именем debug. Вы можете запустить исполняемый файл с помощью этой команды:
$ ./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 координировать сборку.
Не смотря на то, что проект 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"
[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` profile [unoptimized + debuginfo] target(s) in 0.20s
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);
}
Этот код содержит много информации, поэтому давайте рассмотрим его построчно. Чтобы получить пользовательский ввод и затем вывести результат, нам нужно включить в область видимости библиотеку ввода/вывода 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: this `Result` may be an `Err` variant, which should be handled
= note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
|
10 | let _ = io::stdin().read_line(&mut guess);
| +++++++
warning: `guessing_game` (bin "guessing_game") generated 1 warning
Finished `dev` profile [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 = {x} and y + 2 = {}", y + 2); }
Этот код выведет x = 5 and y + 2 = 12
.
Тестирование первой части
Давайте протестируем первую часть игры. Запустите её используя 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. Проект, создаваемый нами, представляет собой
бинарный пакет (binary crate), который является исполняемым файлом. Пакет rand
- это библиотечный пакет (library crate), содержащий код, который предназначен для использования в других программах и поэтому не может исполняться сам по себе.
Координация работы внешних пакетов является тем местом, где Cargo на самом деле блистает. Чтобы начать писать код, использующий rand
, необходимо изменить файл Cargo.toml, включив в него в качестве зависимости пакет rand
. Итак, откройте этот файл и добавьте следующую строку внизу под заголовком секции [dependencies]
, созданным для вас Cargo. Обязательно укажите rand
в точности так же, как здесь, с таким же номером версии, иначе примеры кода из этого урока могут не заработать.
Имя файла: Cargo.toml
[dependencies]
rand = "0.8.5"
В файле Cargo.toml всё, что следует за заголовком, является частью этой секции, которая продолжается до тех пор, пока не начнётся следующая. В [dependencies]
вы сообщаете Cargo, от каких внешних крейтов зависит ваш проект и какие версии этих крейтов вам нужны. В этом случае мы указываем крейт rand
со спецификатором семантической версии 0.8.5
. Cargo понимает семантическое версионирование (иногда называемое SemVer), которое является стандартом для описания версий. Число 0.8.5
на самом деле является сокращением от ^0.8.5
, что означает любую версию не ниже 0.8.5
, но ниже 0.9.0
.
Cargo рассчитывает, что эти версии имеют общедоступное API, совместимое с версией 0.8.5
, и вы получите последние версии исправлений, которые по-прежнему будут компилироваться с кодом из этой главы. Не гарантируется, что версия 0.9.0
или выше будет иметь тот же API, что и в следующих примерах.
Теперь, не меняя ничего в коде, давайте соберём проект, как показано в листинге 2-2.
$ cargo build
Updating crates.io index
Downloaded rand v0.8.5
Downloaded libc v0.2.127
Downloaded getrandom v0.2.7
Downloaded cfg-if v1.0.0
Downloaded ppv-lite86 v0.2.16
Downloaded rand_chacha v0.3.1
Downloaded rand_core v0.6.3
Compiling libc v0.2.127
Compiling getrandom v0.2.7
Compiling cfg-if v1.0.0
Compiling ppv-lite86 v0.2.16
Compiling rand_core v0.6.3
Compiling rand_chacha v0.3.1
Compiling rand v0.8.5
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished dev [unoptimized + debuginfo] target(s) in 2.53s
Вы можете увидеть другие номера версий (но все они будут совместимы с кодом благодаря SemVer), другие строки (в зависимости от операционной системы), а также строки могут быть расположены в другом порядке.
Когда мы включаем внешнюю зависимость, Cargo берет последние версии всего, что нужно этой зависимости, из реестра (registry), который является копией данных с Crates.io. Crates.io — это место, где участники экосистемы 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.6 пакета rand
, и она содержит важное исправление ошибки, но также регрессию, которая может сломать ваш код. Чтобы справиться с этим, Rust создаёт файл Cargo.lock при первом запуске cargo build
, поэтому теперь он есть в каталоге guessing_game.
Когда вы создаёте проект в первый раз, Cargo определяет все версии зависимостей, которые соответствуют критериям, а затем записывает их в файл Cargo.lock. Когда вы будете собирать свой проект в будущем, Cargo увидит, что файл Cargo.lock существует, и будет использовать указанные там версии, а не выполнять всю работу по выяснению версий заново. Это позволяет автоматически создавать воспроизводимую сборку. Другими словами, ваш проект останется на 0.8.5
до тех пор, пока вы явно не обновите его благодаря файлу Cargo.lock. Поскольку файл Cargo.lock важен для воспроизводимых сборок, он часто хранится в системе управления версиями вместе с остальным кодом проекта.
Обновление пакета для получения новой версии
Если вы захотите обновить пакет, Cargo предоставляет команду update
, которая игнорирует файл Cargo.lock и определяет последние версии, соответствующие вашим спецификациям из файла Cargo.toml. После этого Cargo запишет эти версии в файл Cargo.lock. Иначе по умолчанию Cargo будет искать только версии больше 0.8.5, но при этом меньше 0.9.0. Если пакет rand
имеет две новые версии — 0.8.6 и 0.9.0 — то при запуске cargo update
вы увидите следующее:
$ cargo update
Updating crates.io index
Updating rand v0.8.5 -> v0.8.6
Cargo игнорирует релиз 0.9.0. В этот момент также появится изменение в файле Cargo.lock, указывающее на то, что версия rand
, которая теперь используется, равна 0.8.6. Чтобы использовать rand
версии 0.9.0 или любой другой версии из серии 0.9.x, необходимо обновить файл Cargo.toml следующим образом:
[dependencies]
rand = "0.9.0"
В следующий раз, при запуске cargo build
, Cargo обновит реестр доступных пакетов и пересмотрит ваши требования к rand
в соответствии с новой версией, которую вы указали.
Можно много рассказать про Cargo и его экосистему которые мы обсудим в главе 14, сейчас это все что вам нужно знать. Cargo позволяет очень легко повторно использовать библиотеки, поэтому Rust разработчики имеют возможность писать меньшие проекты, которые скомпонованы из многих пакетов.
Генерация случайного числа
Давайте начнём использовать 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}");
}
Сначала мы добавляем строку 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!"),
}
}
Сначала добавим ещё один оператор 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::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.5
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 `&String`, found `&{integer}`
| |
| arguments to this method are incorrect
|
= note: expected reference `&String`
found reference `&{integer}`
note: method defined here
--> file:///home/.rustup/toolchains/1.82/lib/rustlib/src/rust/library/core/src/cmp.rs:838:8
|
838 | fn cmp(&self, other: &Self) -> Ordering;
| ^^^
For more information about this error, try `rustc --explain E0308`.
error: could not compile `guessing_game` (bin "guessing_game") due to 1 previous error
Суть ошибки заключается в наличии несовпадающих типов. У 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
»). Мы будем точно так же обрабатывать данный 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. Запустите программу несколько раз, чтобы проверить разное поведение при различных типах ввода: задайте число правильно, задайте слишком большое число и задайте слишком маленькое число.
Сейчас у нас работает большая часть игры, но пользователь может сделать только одну догадку. Давайте изменим это, добавив цикл!
Возможность нескольких догадок с помощью циклов
Ключевое слово 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;
}
}
}
}
Мы заменяем вызов 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
, независимо от того, какая информация находится внутри. Поэтому программа выполнит код второй ветки, 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;
}
}
}
}
На данный момент вы успешно создали игру в загадки. Поздравляем!
Заключение
Этот проект — практический способ познакомить вас со многими новыми концепциями Rust: let
, match
, функции, использование внешних крейтов и многое другое. В следующих нескольких главах вы изучите эти концепции более подробно. Глава 3 охватывает понятия, которые есть в большинстве языков программирования, такие как переменные, типы данных и функции, и показывает, как использовать их в Rust. В главе 4 рассматривается владение — особенность, которая отличает Rust от других языков. В главе 5 обсуждаются структуры и синтаксис методов, а в главе 6 объясняется, как работают перечисления.
Общие концепции программирования
В этой главе рассматриваются концепции, присутствующие почти в каждом языке программирования, и то, как они работают в Rust. В основе большинства языков программирования есть много общего. Все концепции, представленные в этой главе, не являются уникальными для Rust, но мы обсудим их в контексте Rust и разъясним правила использования этих концепций.
В частности вы изучите переменные, основные типы, функции, комментарии и поток управления. Эти фундаментальные понятия будут присутствовать в каждой программе на Rust, и их изучение на ранней стадии даст вам прочную основу для начала работы.
Ключевые слова
В языке Rust как и в других языках есть набор ключевых слов, зарезервированных только для использования в языке. Помните, что нельзя использовать эти слова в качестве имён переменных или функций. Большинство этих ключевых слов имеют специальные назначения, и вы будете использовать их для выполнения различных задач в своих программах на Rust. Некоторые из них сейчас не имеют функционального назначения, но зарезервированы для функциональности, которая может быть добавлена в Rust в будущем. Список ключевых слов вы можете найти в Приложении А.
Переменные и изменяемость
Как упоминалось в разделе "Хранение значений с помощью переменных", по умолчанию переменные неизменяемы. Это один из многих стимулов Rust, позволяющий писать код с использованием преимущества безопасности и удобной конкурентности (concurrency), предоставляемых 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
. Будет получено сообщение об ошибке относительно неизменяемости, как показано в этом выводе:
error[E0384]: cannot assign twice to immutable variable `x` --> src/main.rs:4:5 | 2 | let x = 5; | - first assignment to `x` 3 | println!("The value of x is: {}", x); 4 | x = 6; | ^^^^^ cannot assign twice to immutable variable
В этом примере показано, как компилятор помогает находить ошибки в ваших программах. Ошибки компилятора могут расстраивать, но в действительности они означают, что программа пока не делает правильно то, что вы ожидаете; это не значит, что вы плохой программист! Даже опытные Rustaceans иногда сталкиваются с ошибками компилятора.
Вы получили сообщение об ошибке 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` profile [unoptimized + debuginfo] target(s) in 0.30s
Running `target/debug/variables`
The value of x is: 5
The value of x is: 6
Нам разрешено изменить значение, связанное с x, с 5 на 6 при помощи mut. В конечном счёте, решение об использовании изменяемости остаётся за вами и зависит от вашего мнения о наилучшем варианте в данной конкретной ситуации.
Константы
Подобно неизменяемым переменным, константы — это значения, которые связаны с именем и не могут изменяться, но между константами и переменными есть несколько различий.
Во-первых, нельзя использовать mut
с константами. Константы не просто неизменяемы по умолчанию — они неизменяемы всегда. Для объявления констант используется ключевое слово const
вместо let
, а также тип значения должен быть указан в аннотации. Мы рассмотрим типы и аннотации типов в следующем разделе «Типы данных»., так что не беспокойтесь о деталях прямо сейчас. Просто знайте, что вы всегда должны аннотировать тип.
Константы можно объявлять в любой области видимости, включая глобальную, благодаря этому они полезны для значений, которые нужны во многих частях кода.
Последнее отличие в том, что константы могут быть заданы только константным выражением, но не результатом вычисленного во время выполнения значения.
Вот пример объявления константы:
#![allow(unused)] fn main() { const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3; }
Имя константы - THREE_HOURS_IN_SECONDS
, а её значение устанавливается как результат умножения 60 (количество секунд в минуте) на 60 (количество минут в часе) на 3 (количество часов, которые нужно посчитать в этой программе). Соглашение Rust для именования констант требует использования всех заглавных букв с подчёркиванием между словами. Компилятор может вычислять ограниченный набор операций во время компиляции, позволяющий записать это значение более понятным и простым для проверки способом, чем установка этой константы в значение 10 800. Дополнительную информацию о том, какие операции можно использовать при объявлении констант, см. в разделе Раздел справки 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` profile [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` (bin "variables") due to 1 previous error
Теперь, когда мы изучили, как работают переменные, давайте рассмотрим различные типы данных, которые они могут иметь.
Типы Данных
Каждое значение в Rust относится к определённому типу данных, который указывает на вид данных, что позволяет Rust знать, как работать с этими данными. Мы рассмотрим два подмножества типов данных: скалярные и составные.
Не забывайте, что Rust является статически типизированным (statically typed) языком. Это означает, что он должен знать типы всех переменных во время компиляции. Обычно компилятор может предположить, какой тип используется (вывести его), основываясь на значении и на том, как мы с ним работаем. В случаях, когда может быть выведено несколько типов, необходимо добавлять аннотацию типа вручную. Например, когда мы конвертировали 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[E0284]: type annotations needed
--> src/main.rs:2:9
|
2 | let guess = "42".parse().expect("Not a number!");
| ^^^^^ ----- type must be known at this point
|
= note: cannot satisfy `<_ as FromStr>::Err == _`
help: consider giving `guess` an explicit type
|
2 | let guess: /* Type */ = "42".parse().expect("Not a number!");
| ++++++++++++
For more information about this error, try `rustc --explain E0284`.
error: could not compile `no_type_annotations` (bin "no_type_annotations") due to 1 previous error
В будущем вы увидите различные аннотации для разных типов данных.
Скалярные типы данных
Скалярный тип представляет собой единичное значение. В Rust есть четыре основных скалярных типа: целочисленный, числа с плавающей точкой, логический и символы. Вы наверняка знакомы с этими типами по другим языкам программирования. Давайте разберёмся, как они работают в Rust.
Целочисленные типы
Целочисленный тип (integer) — это число без дробной части. В главе 2 мы использовали один целочисленный тип — тип u32
. Такое объявление типа указывает, что значение, с которым оно связано, должно быть целым числом без знака (типы целых чисел со знаком начинаются с i
вместо u
), которое занимает 32 бита памяти. В Таблице 3-1 показаны встроенные целочисленные типы в Rust. Мы можем использовать любой из этих вариантов для объявления типа целочисленного значения.
Длина | Со знаком | Без знака |
---|---|---|
8 бит | i8 | u8 |
16 бит | i16 | u16 |
32 бита | i32 | u32 |
64 бита | i64 | u64 |
128 бит | i128 | u128 |
архитектурно-зависимая | isize | usize |
Каждый вариант может быть как со знаком, так и без знака и имеет явный размер. Такая характеристика типа как знаковый и беззнаковый определяет возможность числа быть отрицательным. Другими словами, должно ли число иметь знак (знаковое) или оно всегда будет только положительным и, следовательно, может быть представлено без знака (беззнаковое). Это похоже на написание чисел на бумаге: когда знак имеет значение, число отображается со знаком плюс или со знаком минус; однако, когда можно с уверенностью предположить, что число положительное, оно отображается без знака. Числа со знаком хранятся с использованием дополнительного кода.
Каждый вариант со знаком может хранить числа от -(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
.
Числовой литерал | Пример |
---|---|
Десятичный | 98_222 |
Шестнадцатеричный | 0xff |
восьмеричный | 0o77 |
Двоичный | 0b1111_0000 |
Байт (только u8 ) | b'A' |
Как же узнать, какой тип целого числа использовать? Если вы не уверены, значения по умолчанию в Rust, как правило, подходят для начала: целочисленные типы по умолчанию i32
. Основной случай, в котором вы должны использовать isize
или usize
, — это индексация какой-либо коллекции.
Целочисленное переполнение Допустим, имеется переменная типаu8
, которая может хранить значения от 0 до 255. Если попытаться изменить переменную на значение вне этого диапазона, например, 256, произойдёт целочисленное переполнение, что может привести к одному из двух вариантов поведения. Если выполняется компиляция в режиме отладки, Rust включает проверку на целочисленное переполнение, приводящую вашу программу к панике во время выполнения, когда возникает такое поведение. Rust использует термин паника(panicking), когда программа завершается с ошибкой. Мы обсудим панику более подробно в разделе "Неустранимые ошибки сpanic!
" в главе 9. . При компиляции в режиме release с флагом--release
, Rust не включает проверки на целочисленное переполнение, которое вызывает панику. Вместо этого, в случае переполнения, Rust выполняет обёртывание второго дополнения. Проще говоря, значения, превышающие максимальное значение, которое может хранить тип, "оборачиваются" к минимальному из значений, которые может хранить тип. В случае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 truncated = -5 / 3; // Results in -1 // remainder let remainder = 43 % 5; }
Каждое выражение в этих инструкциях использует математический оператор и вычисляется в одно значение, которое связывается с переменной. Приложении B содержит список всех операторов, которые предоставляет Rust.
Логический тип данных
Как и в большинстве других языков программирования, логический тип в Rust имеет два возможных значения: true
и false
. Значения логических типов имеют размер в один байт. Логический тип в Rust задаётся с помощью bool
. Например:
Файл: src/main.rs
fn main() { let t = true; let f: bool = false; // with explicit type annotation }
Основной способ использования логических значений - это использование условий, таких как выражение if
. Мы рассмотрим, как выражения if
работают в Rust в разделе "Поток управления".
Символьный тип данных
Тип 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
до U+10FFFF
включительно. Однако "символ" не является понятием в Unicode, поэтому ваше человеческое представление о том, что такое "символ", может не совпадать с тем, что такое char
в Rust. Мы подробно обсудим эту тему в главе 8 "Хранение текста в кодировке UTF-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
. Это называется деструктуризацией, поскольку разбивает единый кортеж на три части. Наконец, программа печатает значение 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). Это значение и соответствующий ему тип записываются как ()
и представляет собой пустое значение или пустой возвращаемый тип. Выражения неявно возвращают значение единичного типа, если не возвращают никакого другого значения.
Массивы
Другим способом создания коллекции из нескольких значений является массив array. В отличие от кортежа, каждый элемент массива должен иметь один и тот же тип. В отличие от массивов в некоторых других языках, массивы в 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` profile [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` profile [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
на место пары фигурных скобок, содержащих x
в строке формата.
В сигнатурах функций вы обязаны указывать тип каждого параметра. Это намеренное решение в дизайне 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` profile [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; }
Определения функций также являются инструкцией. Весь предыдущий пример сам по себе является инструкцией.
Инструкции не возвращают значения. Следовательно вы не можете присвоить 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 `let` statement
--> src/main.rs:2:14
|
2 | let x = (let y = 6);
| ^^^
|
= note: only supported directly in conditions of `if` and `while` expressions
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;
|
warning: `functions` (bin "functions") generated 1 warning
error: could not compile `functions` (bin "functions") due to 1 previous error; 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` profile [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: remove this semicolon to return this value
For more information about this error, try `rustc --explain E0308`.
error: could not compile `functions` (bin "functions") due to 1 previous error
Основное сообщение об ошибке, несовпадение типов
, раскрывает ключевую проблему этого кода. Определение функции plus_one
сообщает, что будет возвращено i32
, но инструкции не вычисляются в значение, что и выражается единичным типом ()
. Следовательно, ничего не возвращается, что противоречит определению функции и приводит к ошибке. В этом выводе Rust выдаёт сообщение, которое, возможно, поможет исправить эту проблему: он предлагает удалить точку с запятой для устранения ошибки.
Комментарии
Все программисты стремятся сделать свой код простым для понимания, но иногда требуется дополнительное объяснение. В таких случаях программисты оставляют в исходном коде комментарии, которые компилятор игнорирует, но люди, читающие исходный код, вероятно, сочтут их полезными.
Пример простого комментария:
#![allow(unused)] fn main() { // Hello, world. }
В Rust принят идиоматический стиль комментариев, который начинает комментарий с двух косых черт, и комментарий продолжается до конца строки. Для комментариев, выходящих за пределы одной строки, необходимо включить //
в каждую строку, как показано ниже:
#![allow(unused)] fn main() { // Итак, мы делаем что-то сложное, настолько длинное, что нам нужно // несколько строк комментариев, чтобы сделать это! Ух! Надеюсь, этот комментарий // объясняет, что происходит. }
Комментарии также можно размещать в конце строк, содержащих код:
Имя файла: 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
, за которым следует условное выражение. В данном случае условное выражение проверяет, имеет ли переменная number
значение меньше 5. Сразу после условного выражения внутри фигурных скобок мы помещаем блок кода, который будет выполняться, если результат равен true
. Блоки кода, связанные с условными выражениями, иногда называют ветками, как и ветки в выражениях match
, которые мы обсуждали в разделе "Сравнение догадки с секретным числом" главы 2.
Это необязательно, но мы также можем использовать ключевое слово else
, которое мы используем в данном примере, чтобы предоставить программе альтернативный блок выполнения кода, выполняющийся если результат вычисления будет ложным. Если не указать выражение else
и условие будет ложным, программа просто пропустит блок if
и перейдёт к следующему фрагменту кода.
Попробуйте запустить этот код. Появится следующий результат:
$ cargo run
Compiling branches v0.1.0 (file:///projects/branches)
Finished `dev` profile [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` profile [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` (bin "branches") due to 1 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` profile [unoptimized + debuginfo] target(s) in 0.31s
Running `target/debug/branches`
number is divisible by 3
Во время выполнения этой программы по очереди проверяется каждое выражение if
и выполняется первый блок, для которого условие true
. Заметьте, что хотя 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}"); }
Переменная number
будет привязана к значению, которое является результатом выражения if
. Запустим код и посмотрим, что происходит:
$ cargo run
Compiling branches v0.1.0 (file:///projects/branches)
Finished `dev` profile [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` (bin "branches") due to 1 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
переменная counter
. Когда это происходит, мы используем ключевое слово 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` profile [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!!!"); }
Эта конструкция устраняет множество вложений, которые потребовались бы при использовании loop
, if
, else
и break
, и она более понятна. Пока условие вычисляется в true
, код выполняется; в противном случае происходит выход из цикла.
Цикл по элементам коллекции с помощью 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; } }
Этот код выполняет перебор элементов массива. Он начинается с индекса 0
, а затем циклически выполняется, пока не достигнет последнего индекса в массиве (то есть, когда index < 5
уже не является истиной). Выполнение этого кода напечатает каждый элемент массива:
$ cargo run
Compiling loops v0.1.0 (file:///projects/loops)
Finished `dev` profile [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-4. Что важнее, теперь мы повысили безопасность кода и устранили вероятность ошибок, которые могут возникнуть в результате выхода за пределы массива или недостаточно далёкого перехода и пропуска некоторых элементов.
При использовании цикла for
не нужно помнить о внесении изменений в другой код, в случае изменения количества значений в массиве, как это было бы с методом, использованным в листинге 3-4.
Безопасность и компактность циклов for
делают их наиболее часто используемой конструкцией цикла в Rust. Даже в ситуациях необходимости выполнения некоторого кода определённое количество раз, как в примере обратного отсчёта, в котором использовался цикл while
из Листинга 3-3, большинство Rustaceans использовали бы цикл for
. Для этого можно использовать Range
, предоставляемый стандартной библиотекой, который генерирует последовательность всех чисел, начиная с первого числа и заканчивая вторым числом, но не включая его (т.е. (1..4)
эквивалентно [1, 2, 3]
или в общем случае (start..end)
эквивалентно [start, start+1, start+2, ... , end-2, end-1]
- прим.переводчика).
Вот как будет выглядеть обратный отсчёт с использованием цикла 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 уникальным. В этой главе вы изучите владение на примерах, которые сфокусированы на наиболее часто используемой структуре данных: строках.
Стек и куча
Многие языки программирования не требуют, чтобы вы слишком часто думали о стеке и куче. Но в языках системного программирования, одним из которых является 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 }
Другими словами, здесь есть два важных момента:
- Когда переменная
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; }
Мы можем догадаться, что делает этот код: «привязать значение 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
состоит из трёх частей, показанных слева: указатель на память, в которой хранится содержимое строки, длина и ёмкость. Эта группа данных хранится в стеке. Справа — память в куче, которая содержит содержимое.
Длина — это объём памяти в байтах, который в настоящее время использует содержимое String
. Ёмкость — это общий объём памяти в байтах, который String
получил от распределителя. Разница между длиной и ёмкостью имеет значение, но не в этом контексте, поэтому на данный момент можно игнорировать ёмкость.
Когда мы присваиваем s1
значению s2
, данные String
копируются, то есть мы копируем указатель, длину и ёмкость, которые находятся в стеке. Мы не копируем данные в куче, на которые указывает указатель. Другими словами, представление данных в памяти выглядит так, как показано на рис. 4-2.
Представление не похоже на рисунок 4-3, как выглядела бы память, если бы вместо этого Rust также скопировал данные кучи. Если бы Rust сделал это, операция s2 = s1
могла бы быть очень дорогой с точки зрения производительности во время выполнения, если бы данные в куче были большими.
Ранее мы сказали, что когда переменная выходит за пределы области видимости, 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!("{s1}, world!");
}
Вы получите похожую ошибку, потому что Rust не позволяет вам использовать недействительную ссылку:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:15
|
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!("{s1}, world!");
| ^^^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
|
3 | let s2 = s1.clone();
| ++++++++
For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
Если вы слышали термины поверхностное копирование и глубокое копирование при работе с другими языками, концепция копирования указателя, длины и ёмкости без копирования данных, вероятно, звучит как создание поверхностной копии. Но поскольку Rust также аннулирует первую переменную, вместо того, чтобы называть это поверхностным копированием, это называется перемещением. В этом примере мы бы сказали, что s1
был перемещён в s2
. Итак, что на самом деле происходит, показано на рисунке 4-4.
Это решает нашу проблему! Действительной остаётся только переменная s2
. Когда она выходит из области видимости, то она одна будет освобождать память в куче.
Такой выбор дизайна языка даёт дополнительное преимущество: Rust никогда не будет автоматически создавать «глубокие» копии ваших данных. Следовательно любое такое автоматическое копирование можно считать недорогим с точки зрения производительности во время выполнения.
Взаимодействие переменных и данных с помощью клонирования
Если мы хотим глубоко скопировать данные кучи String
, а не только данные стека, мы можем использовать общий метод, называемый clone
. Мы обсудим синтаксис методов в главе 5, но поскольку методы являются общей чертой многих языков программирования, вы, вероятно, уже встречались с ними.
Вот пример работы метода clone
:
fn main() { let s1 = String::from("hello"); let s2 = s1.clone(); println!("s1 = {s1}, s2 = {s2}"); }
Это отлично работает и очевидно приводит к поведению, представленному на рисунке 4-3, где данные кучи были скопированы.
Когда вы видите вызов clone
, вы знаете о выполнении некоторого кода, который может быть дорогим. В то же время использование clone
является визуальным индикатором того, что тут происходит что-то нестандартное.
Стековые данные: копирование
Это ещё одна особенность о которой мы ранее не говорили. Этот код, часть которого была показа ранее в листинге 4-2, использует целые числа. Он работает без ошибок:
fn main() { let x = 5; let y = x; println!("x = {x}, y = {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.
Если попытаться использовать 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 }
Владение переменной каждый раз следует одному и тому же шаблону: присваивание значения другой переменной перемещает его. Когда переменная, содержащая данные в куче, выходит из области видимости, содержимое в куче будет очищено функцией drop
, если только данные не были перемещены во владение другой переменной.
Хотя это работает, получение права владения, а затем возвращение владения каждой функцией немного утомительно. Что, если мы хотим, чтобы функция использовала значение, но не становилась владельцем? Очень раздражает, что всё, что мы передаём, также должно быть передано обратно, если мы хотим использовать это снова, в дополнение к любым данным, полученным из тела функции, которые мы также можем захотеть вернуть.
Rust позволяет нам возвращать несколько значений с помощью кортежа, как показано в листинге 4-5.
Файл: src/main.rs
fn main() { let s1 = String::from("hello"); let (s2, len) = calculate_length(s1); println!("The length of '{s2}' is {len}."); } fn calculate_length(s: String) -> (String, usize) { let length = s.len(); // len() returns the length of a String (s, length) }
Но это слишком высокопарно и многословно для концепции, которая должна быть общей. К счастью для нас, в 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 '{s1}' is {len}."); } fn calculate_length(s: &String) -> usize { s.len() }
Во-первых, обратите внимание, что весь код кортежа в объявлении переменной и возвращаемое значение функции исчезли. Во-вторых, обратите внимание, что мы передаём &s1
в calculate_length
и в его определении используем &String
, а не String
. Эти амперсанды представляют собой ссылки, и они позволяют вам ссылаться на некоторое значение, не принимая владение над ним. Рисунок 4-5 изображает эту концепцию.
Примечание: противоположностью ссылки с использованием
&
является разыменование, выполняемое с помощью оператора разыменования*
. Мы увидим некоторые варианты использования оператора разыменования в главе 8 и обсудим детали разыменования в главе 15.
Давайте подробнее рассмотрим механизм вызова функции:
fn main() { let s1 = String::from("hello"); let len = calculate_length(&s1); println!("The length of '{s1}' is {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 '{s1}' is {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");
}
Вот ошибка:
$ 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
|
8 | some_string.push_str(", world");
| ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable
|
help: consider changing this to be a mutable reference
|
7 | fn change(some_string: &mut String) {
| +++
For more information about this error, try `rustc --explain E0596`.
error: could not compile `ownership` (bin "ownership") due to 1 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` (bin "ownership") due to 1 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` (bin "ownership") due to 1 previous error
Вау! У нас также не может быть изменяемой ссылки, пока у нас есть неизменяемая ссылка на то же значение.
Пользователи неизменяемой ссылки не ожидают, что значение внезапно изменится из-под них! Однако разрешены множественные неизменяемые ссылки, потому что никто, кто просто читает данные, не может повлиять на чтение данных кем-либо ещё.
Обратите внимание, что область действия ссылки начинается с того места, где она была введена, и продолжается до последнего использования этой ссылки. Например, этот код будет компилироваться, потому что последнее использование неизменяемых ссылок println!
, происходит до того, как вводится изменяемая ссылка:
fn main() { let mut s = String::from("hello"); let r1 = &s; // no problem let r2 = &s; // no problem println!("{r1} and {r2}"); // variables r1 and r2 will not be used after this point let r3 = &mut s; // no problem println!("{r3}"); }
Области неизменяемых ссылок r1
и r2
заканчиваются после println!
где они использовались в последний раз, то есть до создания изменяемой ссылки r3
. Эти области не перекрываются, поэтому этот код разрешён: компилятор может сказать, что ссылка больше не используется в точке перед концом области.
Несмотря на то, что ошибки заимствования могут иногда вызывать разочарование, помните, что компилятор Rust заранее указывает на потенциальную ошибку (во время компиляции, а не во время выполнения) и точно показывает, в чем проблема. Тогда вам не придётся выяснять, почему ваши данные оказались не такими, как вы ожидали.
Висячие ссылки
В языках с указателями весьма легко ошибочно создать недействительную (висячую) (dangling) ссылку. Ссылку указывающую на участок памяти, который мог быть передан кому-то другому, путём освобождения некоторой памяти при сохранении указателя на эту память. Rust компилятор гарантирует, что ссылки никогда не станут недействительными: если у вас есть ссылка на какие-то данные, компилятор обеспечит что эти данные не выйдут из области видимости прежде, чем из области видимости исчезнет ссылка.
Давайте попробуем создать висячую ссылку, чтобы увидеть, как 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, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
|
5 | fn dangle() -> &'static String {
| +++++++
help: instead, you are more likely to want to return an owned value
|
5 - fn dangle() -> &String {
5 + fn dangle() -> String {
|
error[E0515]: cannot return reference to local variable `s`
--> src/main.rs:8:5
|
8 | &s
| ^^ returns a reference to data owned by the current function
Some errors have detailed explanations: E0106, E0515.
For more information about an error, try `rustc --explain E0106`.
error: could not compile `ownership` (bin "ownership") due to 2 previous errors
Это сообщение об ошибке относится к особенности языка, которую мы ещё не рассмотрели: времени жизни. Мы подробно обсудим времена жизни в главе 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() {}
Поскольку нам нужно просмотреть String
поэлементно и проверить, является ли значение пробелом, мы преобразуем нашу String
в массив байтов с помощью метода as_bytes
.
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! }
Данная программа компилируется без ошибок и будет успешно работать, даже после того как мы воспользуемся переменной 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 отображает это на диаграмме.
С синтаксисом Rust ..
, если вы хотите начать с индекса 0, вы можете отбросить значение перед двумя точками. Другими словами, они равны:
#![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` (bin "ownership") due to 1 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 {
Более опытный пользователь Rustacean вместо этого написал бы сигнатуру, показанную в листинге 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);
}
Если у нас есть фрагмент строки, мы можем передать его напрямую. Если у нас есть String
, мы можем передать часть String
или ссылку на String
. Эта гибкость использует преимущества приведения deref, функции, которую мы рассмотрим в разделе «Неявное приведение Deref с функциями и методами». раздел главы 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) — это пользовательский тип данных, позволяющий назвать и упаковать вместе несколько связанных значений, составляющих значимую логическую группу. Если вы знакомы с объектно-ориентированными языками, структура похожа на атрибуты данных объекта. В этой главе мы сравним и сопоставим кортежи со структурами, чтобы опираться на то, что вы уже знаете, и продемонстрируем, когда структуры являются лучшим способом группировки данных.
Мы продемонстрируем, как определять структуры и создавать их экземпляры. Мы обсудим, как определить ассоциированные функции, особенно ассоциированные функции, называемые методами, для указания поведения, ассоциированного с типом структуры. Структуры и перечисления (обсуждаемые в главе 6) являются строительными блоками для создания новых типов в предметной области вашей программы. Они дают возможность в полной мере воспользоваться преимуществами проверки типов во время компиляции Rust.
Определение и инициализация структур
Структуры похожи на кортежи, рассмотренные в разделе "Кортежи", так как оба хранят несколько связанных значений. Как и кортежи, части структур могут быть разных типов. В отличие от кортежей, в структуре необходимо именовать каждую часть данных для понимания смысла значений. Добавление этих имён обеспечивает большую гибкость структур по сравнению с кортежами: не нужно полагаться на порядок данных для указания значений экземпляра или доступа к ним.
Для определения структуры указывается ключевое слово struct
и её название. Название должно описывать значение частей данных, сгруппированных вместе. Далее, в фигурных скобках для каждой новой части данных поочерёдно определяются имя части данных и её тип. Каждая пара имя: тип
называется полем. Листинг 5-1 описывает структуру для хранения информации об учётной записи пользователя:
Имя файла: src/main.rs
struct User { active: bool, username: String, email: String, sign_in_count: u64, } fn main() {}
После определения структуры можно создавать её экземпляр, назначая определённое значение каждому полю с соответствующим типом данных. Чтобы создать экземпляр, мы указываем имя структуры, затем добавляем фигурные скобки и включаем в них пары ключ: значение
(key: value), где ключами являются имена полей, а значениями являются данные, которые мы хотим сохранить в полях. Нет необходимости чётко следовать порядку объявления полей в описании структуры (но всё-таки желательно для удобства чтения). Другими словами, объявление структуры - это как шаблон нашего типа, в то время как экземпляр структуры использует этот шаблон, заполняя его определёнными данными, для создания значений нашего типа. Например, можно объявить пользователя как в листинге 5-2:
Файл: src/main.rs
struct User { active: bool, username: String, email: String, sign_in_count: u64, } fn main() { let user1 = User { active: true, username: String::from("someusername123"), email: String::from("someone@example.com"), sign_in_count: 1, }; }
Чтобы получить конкретное значение из структуры, мы используем запись через точку. Например, чтобы получить доступ к адресу электронной почты этого пользователя, мы используем user1.email
. Если экземпляр является изменяемым, мы можем поменять значение, используя точечную нотацию и присвоение к конкретному полю. В Листинге 5-3 показано, как изменить значение в поле email
изменяемого экземпляра User
.
Файл: src/main.rs
struct User { active: bool, username: String, email: String, sign_in_count: u64, } fn main() { let mut user1 = User { active: true, username: String::from("someusername123"), email: String::from("someone@example.com"), sign_in_count: 1, }; user1.email = String::from("anotheremail@example.com"); }
Стоит отметить, что весь экземпляр структуры должен быть изменяемым; Rust не позволяет помечать изменяемыми отдельные поля. Как и для любого другого выражения, мы можем использовать выражение создания структуры в качестве последнего выражения тела функции для неявного возврата нового экземпляра.
На листинге 5-4 функция build_user
возвращает экземпляр User
с указанным адресом и именем. Поле active
получает значение true
, а поле sign_in_count
получает значение 1
.
Файл: src/main.rs
struct User { active: bool, username: String, email: String, sign_in_count: u64, } fn build_user(email: String, username: String) -> User { User { active: true, username: username, email: email, sign_in_count: 1, } } fn main() { let user1 = build_user( String::from("someone@example.com"), String::from("someusername123"), ); }
Имеет смысл называть параметры функции теми же именами, что и поля структуры, но необходимость повторять email
и username
для названий полей и переменных несколько утомительна. Если структура имеет много полей, повторение каждого имени станет ещё более раздражающим. К счастью, есть удобное сокращение!
Использование сокращённой инициализации поля
Так как имена входных параметров функции и полей структуры являются полностью идентичными в листинге 5-4, возможно использовать синтаксис сокращённой инициализации поля, чтобы переписать build_user
так, чтобы он работал точно также, но не содержал повторений для username
и email
, как в листинге 5-5.
Файл: src/main.rs
struct User { active: bool, username: String, email: String, sign_in_count: u64, } fn build_user(email: String, username: String) -> User { User { active: true, username, email, sign_in_count: 1, } } fn main() { let user1 = build_user( String::from("someone@example.com"), String::from("someusername123"), ); }
Здесь происходит создание нового экземпляра структуры User
, которая имеет поле с именем email
. Мы хотим установить поле структуры email
значением входного параметра email
функции build_user
. Так как поле email
и входной параметр функции email
имеют одинаковое название, можно писать просто email
вместо кода email: email
.
Создание экземпляра структуры из экземпляра другой структуры с помощью синтаксиса обновления структуры
Часто бывает полезно создать новый экземпляр структуры, который включает большинство значений из другого экземпляра, но некоторые из них изменяет. Это можно сделать с помощью синтаксиса обновления структуры.
Сначала в листинге 5-6 показано, как обычно создаётся новый экземпляр User
в user2
без синтаксиса обновления. Мы задаём новое значение для email
, но в остальном используем те же значения из user1
, которые были заданы в листинге 5-2.
Файл: src/main.rs
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-7. Синтаксис ..
указывает, что оставшиеся поля устанавливаются неявно и должны иметь значения из указанного экземпляра.
Файл: src/main.rs
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 также создаёт экземпляр в 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
, поэтому они ведут себя так, как мы обсуждали в разделе «Стековые данные: копирование».
Кортежные структуры: структуры без именованных полей для создания разных типов
Rust также поддерживает структуры, похожие на кортежи, которые называются кортежные структуры. Кортежные структуры обладают дополнительным смыслом, который даёт имя структуры, но при этом не имеют имён, связанных с их полями. Скорее, они просто хранят типы полей. Кортежные структуры полезны, когда вы хотите дать имя всему кортежу и сделать кортеж отличным от других кортежей, и когда именование каждого поля, как в обычной структуре, было бы многословным или избыточным.
Чтобы определить кортежную структуру, начните с ключевого слова struct
и имени структуры, за которым следуют типы в кортеже. Например, здесь мы определяем и используем две кортежные структуры с именами Color
и Point
:
Файл: src/main.rs
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
:
Файл: src/main.rs
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 { active: true, username: "someusername123", email: "someone@example.com", 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 }
Теперь запустим программу, используя cargo run
:
$ cargo run
Compiling rectangles v0.1.0 (file:///projects/rectangles)
Finished `dev` profile [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 }
С одной стороны, эта программа лучше. Кортежи позволяют добавить немного структуры, и теперь мы передаём только один аргумент. Но с другой стороны, эта версия менее понятна: кортежи не называют свои элементы, поэтому нам приходится индексировать части кортежа, что делает наше вычисление менее очевидным.
Если мы перепутаем местами ширину с высотой при расчёте площади, то это не имеет значения. Но если мы хотим нарисовать прямоугольник на экране, то это уже будет важно! Мы должны помнить, что ширина 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 }
Здесь мы определили структуру и дали ей имя 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!
, который мы уже использовали в предыдущих главах. Тем не менее, это не работает.
Файл: src/main.rs
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!("rect1 is {}", rect1);
}
При компиляции этого кода мы получаем ошибку с сообщением:
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:?}"); }
Теперь при запуске программы мы не получим ошибок и увидим следующий вывод:
$ cargo run
Compiling rectangles v0.1.0 (file:///projects/rectangles)
Finished `dev` profile [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` profile [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` profile [unoptimized + debuginfo] target(s) in 0.61s
Running `target/debug/rectangles`
[src/main.rs:10:16] 30 * scale = 60
[src/main.rs:14:5] &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() ); }
Чтобы определить функцию в контексте 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));
}
Ожидаемый результат будет выглядеть следующим образом, т.к. оба размера в экземпляре 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)); }
Когда мы запустим код с функцией 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)); }
Здесь нет причин разделять методы на несколько impl
, но это допустимый синтаксис. Мы увидим случай, когда несколько impl
могут оказаться полезными, в Главе 10, рассматривающей обобщённые типы и свойства.
Итоги
Структуры позволяют создавать собственные типы, которые имеют смысл в вашей предметной области. Используя структуры, вы храните ассоциированные друг с другом фрагменты данных и даёте название частям данных, чтобы ваш код был более понятным. Методы позволяют определить поведение, которое имеют экземпляры ваших структур, а ассоциированные функции позволяют привязать функциональность к вашей структуре, не обращаясь к её экземпляру.
Но структуры — не единственный способ создавать собственные типы: давайте обратимся к перечислениям в Rust, чтобы добавить ещё один инструмент в свой арсенал.
Перечисления и сопоставление с образцом
В этой главе мы рассмотрим перечисления (enumerations), также называемые enums. Перечисления позволяют определить тип путём перечисления его возможных вариантов . Сначала мы определим и используем перечисление, чтобы показать, как оно может объединить значения и данные. Далее мы рассмотрим особенно полезное перечисление под названием Option
, которое выражает, что значение может быть либо чем-то, либо ничем. Затем мы рассмотрим, как сопоставление с образцом в выражении match
позволяет легко запускать разный код для разных значений перечисления. Наконец, мы узнаем, насколько конструкция if let
удобна и лаконична для обработки перечислений в вашем коде.
Определение перечисления
Там, где структуры дают вам возможность группировать связанные поля и данные, например Rectangle
с его width
и height
, перечисления дают вам способ сказать, что значение является одним из возможных наборов значений. Например, мы можем захотеть сказать, что Rectangle
— это одна из множества возможных фигур, в которую также входят Circle
и Triangle
. Для этого Rust позволяет нам закодировать эти возможности в виде перечисления.
Давайте рассмотрим ситуацию, которую мы могли бы захотеть отразить в коде, и поймём, почему перечисления полезны и более уместны, чем структуры в этом случае. Допустим, нам нужно работать с IP-адресами. В настоящее время для обозначения IP-адресов используются два основных стандарта: четвёртая и шестая версии. Поскольку это единственно возможные варианты IP-адресов, с которыми может столкнуться наша программа, мы можем перечислить все возможные варианты, откуда перечисление и получило своё название.
Любой IP-адрес может быть либо четвёртой, либо шестой версии, но не обеими одновременно. Эта особенность IP-адресов делает структуру данных enum подходящей, поскольку значение enum может представлять собой только один из его возможных вариантов. Адреса как четвёртой, так и шестой версии по своей сути все равно являются 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"), }; }
Здесь мы определили структуру IpAddr
, у которой есть два поля: kind
типа IpAddrKind
(перечисление, которое мы определили ранее) и address
типа String
. У нас есть два экземпляра этой структуры. Первый - home
, который является IpAddrKind::V4
в качестве значения kind
с соответствующим адресом 127.0.0.1
. Второй экземпляр - loopback
. Он в качестве значения kind
имеет другой вариант IpAddrKind
, 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
: в ней есть точно такое же перечисление с вариантами, которое мы определили и использовали, но она помещает данные об адресе внутрь этих вариантов в виде двух различных структур, которые имеют различные определения для каждого из вариантов:
#![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() {}
Это перечисление имеет 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-значениями
В этом разделе рассматривается пример использования Option
, ещё одного перечисления, определённого в стандартной библиотеке. Тип Option
кодирует очень распространённый сценарий, в котором значение может быть чем-то, а может быть ничем.
Например, если вы запросите первый элемент из непустого списка, вы получите значение. Если вы запросите первый элемент пустого списка, вы ничего не получите. Выражение этой концепции в терминах системы типов означает, что компилятор может проверить, обработали ли вы все случаи, которые должны были обработать; эта функциональность может предотвратить ошибки, которые чрезвычайно распространены в других языках программирования.
Дизайн языка программирования часто рассматривается с точки зрения того, какие функции вы включаете в него, но те функции, которые вы исключаете, также важны. Например в Rust нет такого функционала как null значения, однако он есть во многих других языках. Null значение - это значение, которое означает, что значения нет. В языках с null значением переменные всегда могут находиться в одном из двух состояний: нет значения (null) или есть значение (not-null).
В своей презентации 2009 года «Null ссылки: ошибка в миллиард долларов» Тони Хоар (Tony Hoare), изобретатель null, сказал следующее:
Я называю это своей ошибкой на миллиард долларов. В то время я разрабатывал первую комплексную систему типов для ссылок на объектно-ориентированном языке. Моя цель состояла в том, чтобы гарантировать, что любое использование ссылок должно быть абсолютно безопасным, с автоматической проверкой компилятором. Но я не мог устоять перед соблазном вставить пустую ссылку просто потому, что это было так легко реализовать. Это привело к бесчисленным ошибкам, уязвимостям и системным сбоям, которые, вероятно, причинили боль и ущерб на миллиард долларов за последние сорок лет.
Проблема с null значениями заключается в том, что если вы попытаетесь использовать null значение в качестве not-null значения, вы получите ошибку определённого рода. Поскольку свойство null или not-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
представляют собой его варианты.
<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_char
- Option<char>
, это другой тип. 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>
даже если бы оно было определённо допустимым значением. Например, этот код не будет компилироваться, потому что он пытается добавить 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`
= help: the following other types implement trait `Add<Rhs>`:
`&'a i8` implements `Add<i8>`
`&i8` implements `Add<&i8>`
`i8` implements `Add<&i8>`
`i8` implements `Add`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` (bin "enums") due to 1 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.
Устранение риска ошибочного предположения касательно не-null значения помогает вам быть более уверенным в своём коде. Чтобы иметь значение, которое может быть null, вы должны явно описать тип этого значения с помощью Option<T>
. Затем, когда вы используете это значение, вы обязаны явно обрабатывать случай, когда значение равно null. Везде, где значение имеет тип, отличный от Option<T>
, вы можете смело рассчитывать на то, что значение не равно null. Это продуманное проектное решение в Rust, ограничивающее распространение null и увеличивающее безопасность кода на Rust.
Итак, как же получить значение T
из варианта Some
, если у вас на руках есть только объект 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() {}
Давайте разберём 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() {}
Представьте, что ваш друг пытается собрать четвертаки всех 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); }
Давайте более подробно рассмотрим первое выполнение plus_one
. Когда мы вызываем plus_one(five)
, переменная x
в теле plus_one
будет иметь значение Some(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);
}
Значение 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
--> file:///home/.rustup/toolchains/1.82/lib/rustlib/src/rust/library/core/src/option.rs:571:1
|
571 | pub enum Option<T> {
| ^^^^^^^^^^^^^^^^^^
...
575 | None,
| ---- not covered
= 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` (bin "enums") due to 1 previous error
Rust знает, что мы не описали все возможные случаи, и даже знает, какой именно из шаблонов мы упустили! Сопоставления в Rust являются исчерпывающими: мы должны покрыть все возможные варианты, чтобы код был корректным. Особенно в случае 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
. Код, выполняемый для ветки other
, использует эту переменную, передавая её в функцию move_player
.
Этот код компилируется, даже если мы не перечислили все возможные значения u8
, потому что последний паттерн будет соответствовать всем значениям, не указанным в конкретном списке. Этот универсальный шаблон удовлетворяет требованию, что соответствие должно быть исчерпывающим. Обратите внимание, что мы должны поместить ветку с универсальным шаблоном последней, потому что шаблоны оцениваются по порядку. Rust предупредит нас, если мы добавим ветки после универсального шаблона, потому что эти последующие ветки никогда не будут выполняться!
В Rust также есть шаблон, который можно использовать, когда мы не хотим использовать значение в универсальном шаблоне: _
, который является специальным шаблоном, который соответствует любому значению и не привязывается к этому значению. Это говорит 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
, который может быть полезен в ситуациях, когда выражение match
слишком многословно.
Компактное управление потоком выполнения с if let
Синтаксис if let
позволяет скомбинировать if
и let
в менее многословную конструкцию, и затем обработать значения соответствующе только одному шаблону, одновременно игнорируя все остальные. Рассмотрим программу в листинге 6-6, которая обрабатывает сопоставление значения Option<u8>
в переменной config_max
, но хочет выполнить код только в том случае, если значение является вариантом Some
.
fn main() { let config_max = Some(3u8); match config_max { Some(max) => println!("The maximum is configured to be {max}"), _ => (), } }
Если значение равно Some
, мы распечатываем значение в варианте Some
, привязывая значение к переменной max
в шаблоне. Мы не хотим ничего делать со значением 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
принимает шаблон и выражение, разделённые знаком равенства. Он работает так же, как match
, когда в него на вход передадут выражение и подходящим шаблоном для этого выражения окажется первая ветка. В данном случае шаблоном является Some(max)
, где max
привязывается к значению внутри Some
. Затем мы можем использовать max
в теле блока if let
так же, как мы использовали max
в соответствующей ветке match
. Код в блоке if let
не запускается, если значение не соответствует шаблону.
Используя if let
мы меньше печатаем, меньше делаем отступов и меньше получаем шаблонного кода. Тем не менее, мы теряем полную проверку всех вариантов, предоставляемую выражением match
. Выбор между match
и if let
зависит от того, что вы делаете в вашем конкретном случае и является ли получение краткости при потере полноты проверки подходящим компромиссом.
Другими словами, вы можете думать о конструкции if let
как о синтаксическом сахаре для 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 обеспечивает типобезопасность: компилятор позаботится о том, чтобы функции получали значения только того типа, который они ожидают.
Чтобы предоставить вашим пользователям хорошо организованный API, который прост в использовании и предоставляет только то, что нужно вашим пользователям, надо поговорить о модулях в Rust.
Управление растущими проектами с помощью пакетов, крейтов и модулей
По мере роста кодовой базы ваших программ, организация проекта будет иметь большое значение, ведь отслеживание всей программы в голове будет становиться всё более сложным. Группируя связанные функции и разделяя код по основным функциональностям (фичам, feature), вы делаете более прозрачным понимание о том, где искать код реализующий определённую функцию и где стоит вносить изменения для того чтобы изменить её поведение.
Программы, которые мы писали до сих пор, были в одном файле одного модуля. По мере роста проекта, мы можем организовывать код иначе, разделив его на несколько модулей и несколько файлов. Пакет может содержать несколько бинарных крейтов и опционально один крейт библиотеки. Пакет может включать в себя много бинарных крейтов и опционально один библиотечный крейт. По мере роста пакета вы можете извлекать части программы в отдельные крейты, которые затем станут внешними зависимостями для основного кода нашей программы. Эта глава охватывает все эти техники. В свою очередь для очень крупных проектов, состоящих из набора взаимосвязанных пакетов развивающихся вместе, Cargo предоставляет рабочие пространства, workspaces, их мы рассмотрим за пределами данной главы, в разделе "Рабочие пространства Cargo" Главы 14.
Мы также обсудим инкапсуляцию деталей, которая позволяет использовать код снова на более высоком уровне: единожды реализовав какую-то операцию, другой код может вызывать этот код через публичный интерфейс, не зная как работает реализация. То, как вы пишете код, определяет какие части общедоступны для использования другим кодом и какие части являются закрытыми деталями реализации для которых вы оставляете право на изменения только за собой. Это ещё один способ ограничить количество деталей, которые вы должны держать в голове.
Связанное понятие - это область видимости: вложенный контекст в котором написан код имеющий набор имён, которые определены «в текущей области видимости». При чтении, письме и компиляции кода, программистам и компиляторам необходимо знать, относится ли конкретное имя в определённом месте к переменной, к функции, к структуре, к перечислению, к модулю, к константе или другому элементу и что означает этот элемент. Можно создавать области видимости и изменять какие имена входят или выходят за их рамки. Нельзя иметь два элемента с тем же именем в одной области; есть доступные инструменты для разрешения конфликтов имён.
Rust имеет ряд функций, которые позволяют управлять организацией кода, в том числе управлять тем какие детали открыты, какие детали являются частными, какие имена есть в каждой области вашей программы. Эти функции иногда вместе именуемые модульной системой включают в себя:
- Пакеты: Функционал Cargo позволяющий собирать, тестировать и делиться крейтами
- Крейты: Дерево модулей, которое создаёт библиотечный или исполняемый файл
- Модули и use: Позволяют вместе контролировать организацию, область видимости и скрытие путей
- Пути: способ именования элемента, такого как структура, функция или модуль
В этой главе мы рассмотрим все эти функции, обсудим как они взаимодействуют и объясним, как использовать их для управления областью видимости. К концу у вас должно появиться солидное понимание модульной системы и умение работать с областями видимости на уровне профессионала!
Пакеты и крейты
Первые части модульной системы, которые мы рассмотрим — это пакеты и крейты.
Крейт — это наименьший объем кода, который компилятор Rust рассматривает за раз. Даже если вы запустите rustc
вместо cargo
и передадите один файл с исходным кодом (как мы уже делали в разделе «Написание и запуск программы на Rust» Главы 1), компилятор считает этот файл крейтом. Крейты могут содержать модули, и модули могут быть определены в других файлах, которые компилируются вместе с крейтом, как мы увидим в следующих разделах.
Крейт может быть одним из двух видов: бинарный крейт или библиотечный крейт. Бинарные крейты — это программы, которые вы можете скомпилировать в исполняемые файлы, которые вы можете запускать, например программу командной строки или сервер. У каждого бинарного крейта должна быть функция с именем main
, которая определяет, что происходит при запуске исполняемого файла. Все крейты, которые мы создали до сих пор, были бинарными крейтами.
Библиотечные крейты не имеют функции main
и не компилируются в исполняемый файл. Вместо этого они определяют функциональность, предназначенную для совместного использования другими проектами. Например, крейт rand
, который мы использовали в Главе 2 обеспечивает функциональность, которая генерирует случайные числа. В большинстве случаев, когда Rustaceans говорят «крейт», они имеют в виду библиотечный крейт, и они используют «крейт» взаимозаменяемо с общей концепцией программирования «библиотека».
Корневой модуль крейта — это исходный файл, из которого компилятор Rust начинает собирать корневой модуль вашего крейта (мы подробно объясним модули в разделе «Определение модулей для контроля видимости и закрытости»).
Пакет — это набор из одного или нескольких крейтов, предоставляющий набор функциональности. Пакет содержит файл Cargo.toml, в котором описывается, как собирать эти крейты. На самом деле Cargo — это пакет, содержащий бинарный крейт для инструмента командной строки, который вы использовали для создания своего кода. Пакет Cargo также содержит библиотечный крейт, от которого зависит бинарный крейт. Другие проекты тоже могут зависеть от библиотечного крейта Cargo, чтобы использовать ту же логику, что и инструмент командной строки Cargo.
Пакет может содержать сколько угодно бинарных крейтов, но не более одного библиотечного крейта. Пакет должен содержать хотя бы один крейт, библиотечный или бинарный.
Давайте пройдёмся по тому, что происходит, когда мы создаём пакет. Сначала введём команду 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 new
, мы используем ls
, чтобы увидеть, что создал Cargo. В каталоге проекта есть файл Cargo.toml, дающий нам пакет. Также есть каталог src, содержащий main.rs. Откройте 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: каждый файл будет отдельным бинарным крейтом.
Определение модулей для контроля видимости и закрытости
В этом разделе мы поговорим о модулях и других частях системы модулей, а именно: путях (paths), которые позволяют именовать элементы; ключевом слове use
, которое приносит путь в область видимости; ключевом слове pub
, которое делает элементы общедоступными. Мы также обсудим ключевое слово as
, внешние пакеты и оператор glob. А пока давайте сосредоточимся на модулях!
Во-первых, мы начнём со списка правил, чтобы вам было легче ориентироваться при организации кода в будущем. Затем мы подробно объясним каждое из правил.
Шпаргалка по модулям
Здесь мы даём краткий обзор того, как модули, пути, ключевое слово use
и ключевое слово pub
работают в компиляторе и как большинство разработчиков организуют свой код. В этой главе мы рассмотрим примеры каждого из этих правил, и это удобный момент чтобы напомнить о том, как работают модули.
- Начнём с корня крейта: при компиляции компилятор сначала ищет корневой модуль крейта (обычно это src/lib.rs для библиотечного крейта или src/main.rs для бинарного крейта) для компиляции кода.
- Объявление модулей: В файле корневого модуля крейта вы можете объявить новые модули; скажем, вы объявляете модуль “garden” с помощью
mod garden;
. Компилятор будет искать код модуля в следующих местах:- в этом же файле, между фигурных скобок, которые заменяют точку с запятой после
mod garden
- в файле src/garden.rs
- в файле src/garden/mod.rs
- в этом же файле, между фигурных скобок, которые заменяют точку с запятой после
- Объявление подмодулей: В любом файле, кроме корневого модуля крейта, вы можете объявить подмодули. К примеру, вы можете объявить
mod vegetables;
в src/garden.rs. Компилятор будет искать код подмодуля в каталоге с именем родительского модуля в следующих местах:- в этом же файле, сразу после
mod vegetables
, между фигурных скобок, которые заменяют точку с запятой - в файле src/garden/vegetables.rs
- в файле src/garden/vegetables/mod.rs
- в этом же файле, сразу после
- Пути к коду в модулях: После того, как модуль станет частью вашего крейта и если допускают правила приватности, вы можете ссылаться на код в этом модуле из любого места вашего крейта, используя путь к коду. Например, тип
Asparagus
, в подмодуле vegetables модуля garden, будет найден по путиcrate::garden::vegetables::Asparagus
. - Скрытие или общедоступность: Код в модуле по умолчанию скрыт от родительского модуля. Чтобы сделать модуль общедоступным, объявите его как
pub mod
вместоmod
. Чтобы сделать элементы общедоступного модуля тоже общедоступными, используйтеpub
перед их объявлением. - Ключевое слово
use
: Внутри области видимости использование ключевого словаuse
создаёт псевдонимы для элементов, чтобы уменьшить повторение длинных путей. В любой области видимости, в которой может обращаться кcrate::garden::vegetables::Asparagus
, вы можете создать псевдонимuse crate::garden::vegetables::Asparagus;
и после этого вам нужно просто писатьAsparagus
, чтобы использовать этот тип в этой области видимости.
Мы создали бинарный крейт backyard
, который иллюстрирует эти правила. Директория крейта, также названная как backyard
, содержит следующие файлы и директории:
backyard
├── Cargo.lock
├── Cargo.toml
└── src
├── garden
│ └── vegetables.rs
├── garden.rs
└── main.rs
Файл корневого модуля крейта в нашем случае src/main.rs, и его содержимое:
Файл: src/main.rs
use crate::garden::vegetables::Asparagus;
pub mod garden;
fn main() {
let plant = Asparagus {};
println!("I'm growing {plant:?}!");
}
Строка pub mod garden;
говорит компилятору о подключении кода, найденном в src/garden.rs:
Файл: src/garden.rs
pub mod vegetables;
А здесь pub mod vegetables;
указывает на подключаемый код в src/garden/vegetables.rs. Этот код:
#[derive(Debug)]
pub struct Asparagus {}
Теперь давайте рассмотрим детали этих правил и продемонстрируем их в действии!
Группировка связанного кода в модулях
Модули позволяют упорядочивать код внутри крейта для удобочитаемости и лёгкого повторного использования. Модули также позволяют нам управлять приватностью элементов, поскольку код внутри модуля по умолчанию является закрытым. Частные элементы — это внутренние детали реализации, недоступные для внешнего использования. Мы можем сделать модули и элементы внутри них общедоступными, что позволит внешнему коду использовать их и зависеть от них.
В качестве примера, давайте напишем библиотечный крейт предоставляющий функциональность ресторана. Мы определим сигнатуры функций, но оставим их тела пустыми, чтобы сосредоточиться на организации кода, вместо реализации кода для ресторана.
В ресторанной индустрии некоторые части ресторана называются фронтом дома, а другие задней частью дома. Фронт дома это там где находятся клиенты; здесь размещаются места клиентов, официанты принимают заказы и оплаты, а бармены делают напитки. Задняя часть дома это где шеф-повара и повара работают на кухне, работают посудомоечные машины, а менеджеры занимаются административной деятельностью.
Чтобы структурировать крейт аналогично тому, как работает настоящий ресторан, можно организовать размещение функций во вложенных модулях. Создадим новую библиотеку (библиотечный крейт) с именем restaurant
выполнив команду cargo new restaurant --lib
; затем вставим код из листинга 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() {}
}
}
Мы определяем модуль, начиная с ключевого слова 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
Это дерево показывает, как некоторые из модулей вкладываются друг в друга; например, 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();
}
При первом вызове функции add_to_waitlist
из eat_at_restaurant
мы используем абсолютный путь. Функция add_to_waitlist
определена в том же крейте, что и eat_at_restaurant
, и это означает, что мы можем использовать ключевое слово 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();
| ^^^^^^^ --------------- function `add_to_waitlist` is not publicly re-exported
| |
| 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();
| ^^^^^^^ --------------- function `add_to_waitlist` is not publicly re-exported
| |
| 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` (lib) due to 2 previous errors
Сообщения об ошибках говорят о том, что модуль hosting
является приватным. Другими словами, у нас есть правильные пути к модулю hosting
и функции add_to_waitlist
, но Rust не позволяет нам использовать их, потому что у него нет доступа к приватным разделам. В Rust все элементы (функции, методы, структуры, перечисления, модули и константы) по умолчанию являются приватными для родительских модулей. Если вы хотите сделать элемент, например функцию или структуру, приватным, вы помещаете его в модуль.
Элементы в родительском модуле не могут использовать приватные элементы внутри дочерних модулей, но элементы в дочерних модулях могут использовать элементы у своих модулях-предках. Это связано с тем, что дочерние модули оборачивают и скрывают детали своей реализации, но дочерние модули могут видеть контекст, в котором они определены. Продолжая нашу метафору, подумайте о правилах приватности как о задней части ресторана: то, что там происходит, скрыто от клиентов ресторана, но офис-менеджеры могут видеть и делать всё в ресторане, которым они управляют.
В Rust решили, что система модулей должна функционировать таким образом, чтобы по умолчанию скрывать детали реализации. Таким образом, вы знаете, какие части внутреннего кода вы можете изменять не нарушая работы внешнего кода. Тем не менее, Rust даёт нам возможность открывать внутренние части кода дочерних модулей для внешних модулей-предков, используя ключевое слово pub
, чтобы сделать элемент общедоступным.
Раскрываем приватные пути с помощью ключевого слова pub
Давайте вернёмся к ошибке в листинге 7-4, которая говорит, что модуль hosting
является приватным. Мы хотим, чтобы функция 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 всё ещё приводит к ошибке, как показано в листинге 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` (lib) due to 2 previous errors
Что произошло? Добавление ключевого слова 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();
}
Теперь код компилируется! Чтобы понять, почему добавление ключевого слова 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
, остальная часть пути работает и вызов этой функции разрешён!
Если вы планируете предоставить общий доступ к своему библиотечному крейту, чтобы другие проекты могли использовать ваш код, ваш общедоступный API — это ваш контракт с пользователями вашего крейта, определяющий, как они могут взаимодействовать с вашим кодом. Есть много соображений по поводу управления изменениями в вашем общедоступном API, чтобы сделать необременительным для людей зависимость от вашего крейта. Эти соображения выходят за рамки этой книги; если вам интересна эта тема, см. The Rust API Guidelines.
Лучшие практики для пакетов с бинарным и библиотечным крейтами
Мы упоминали, что пакет может содержать как корневой модуль бинарного крейта src/main.rs, так и корневой модуль библиотечного крейта src/lib.rs, и оба крейта будут по умолчанию иметь имя пакета. Как правило, пакеты с таким шаблоном, содержащим как библиотечный, так и бинарный крейт, будут иметь достаточно кода в бинарном крейте, чтобы запустить исполняемый файл, который вызывает код из библиотечного крейта. Это позволяет другим проектам извлечь выгоду из большей части функциональности, предоставляемой пакетом, поскольку код библиотечного крейта можно использовать совместно.
Дерево модулей должно быть определено в src/lib.rs. Затем любые общедоступные элементы можно использовать в бинарном крейте, начав пути с имени пакета. Бинарный крейт становится пользователем библиотечного крейта точно так же, как полностью внешний крейт использует библиотечный крейт: он может использовать только общедоступный API. Это поможет вам разработать хороший API; вы не только автор, но и пользователь!
В Главе 12 мы эту практику организации кода с помощью консольной программы, которая будет содержать как бинарный, так и библиотечный крейты.
Начинаем относительный путь с помощью super
Также можно построить относительные пути, которые начинаются в родительском модуле, используя ключевое слово 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() {}
}
Функция fix_incorrect_order
находится в модуле back_of_house
, поэтому мы можем использовать super
для перехода к родительскому модулю модуля back_of_house
, который в этом случае является crate
, корневым модулем. В этом модуле мы ищем deliver_order
и находим его. Успех! Мы думаем, что модуль back_of_house
и функция deliver_order
, скорее всего, останутся в тех же родственных отношениях друг с другом, и должны будут перемещены вместе, если мы решим реорганизовать дерево модулей крейта. Поэтому мы использовали super
, чтобы в будущем у нас было меньше мест для обновления кода, если этот код будет перемещён в другой модуль.
Делаем общедоступными структуры и перечисления
Мы также можем использовать pub
для обозначения структур и перечислений как общедоступных, но есть несколько дополнительных деталей использования 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");
}
Поскольку поле 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;
}
Поскольку мы сделали общедоступным перечисление 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();
}
Добавление 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();
}
}
Ошибка компилятора показывает, что данный псевдоним не может использоваться в модуле 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`
|
help: consider importing this module through its public re-export
|
10 + use crate::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` (lib) due to 1 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-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); }
За этой идиомой нет веской причины: это просто соглашение, которое появилось само собой. Люди привыкли читать и писать код на 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(())
}
Как видите, использование имени родительских модулей позволяет различать два типа 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(())
}
Во второй инструкции 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();
}
До этого изменения внешний код должен был вызывать функцию 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.5"
Добавление 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!"),
}
}
В больших программах, подключение множества элементов из одного пакета или модуля с использованием вложенных путей может значительно сократить количество необходимых отдельных инструкций use
!
Можно использовать вложенный путь на любом уровне, что полезно при объединении двух инструкций use
, которые имеют общую часть пути. Например, в листинге 7-19 показаны две инструкции use
: одна подключает std::io
, а другая подключает std::io::Write
в область видимости.
Файл: src/lib.rs
use std::io;
use std::io::Write;
Общей частью этих двух путей является std::io
, и это полный первый путь. Чтобы объединить эти два пути в одной инструкции use
, мы можем использовать ключевое слово self
во вложенном пути, как показано в листинге 7-20.
Файл: src/lib.rs
use std::io::{self, Write};
Эта строка подключает std::io
и std::io::Write
в область видимости.
Оператор * (glob)
Если мы хотим включить в область видимости все общедоступные элементы, определённые в пути, мы можем указать этот путь, за которым следует оператор *
:
#![allow(unused)] fn main() { use std::collections::*; }
Эта инструкция use
подключает все открытые элементы из модуля std::collections
в текущую область видимости. Будьте осторожны при использовании оператора *
! Он может усложнить понимание, какие имена находятся в области видимости и где были определены имена, используемые в вашей программе.
Оператор *
часто используется при тестировании для подключения всего что есть в модуле tests
; мы поговорим об этом в разделе "Как писать тесты" Главы 11. Оператор *
также иногда используется как часть шаблона автоматического импорта (prelude): смотрите документацию по стандартной библиотеке для получения дополнительной информации об этом шаблоне.
Разделение модулей на разные файлы
До сих пор все примеры в этой главе определяли несколько модулей в одном файле. Когда модули становятся большими, вы можете захотеть переместить их определения в отдельные файлы, чтобы упростить навигацию по коду.
Например, давайте начнём с кода из листинга 7-17, в котором было несколько модулей ресторана. Мы будем извлекать модули в файлы вместо того, чтобы определять все модули в корневом модуле крейта. В нашем случае корневой модуль крейта - src/lib.rs, но это разделение также работает и с бинарными крейтами, у которых корневой модуль крейта — src/main.rs.
Сначала мы извлечём модуль front_of_house
в свой собственный файл. Удалите код внутри фигурных скобок для модуля front_of_house
, оставив только объявление mod front_of_house;
, так что теперь src/lib.rs содержит код, показанный в листинге 7-21. Обратите внимание, что этот вариант не скомпилируется, пока мы не создадим файл src/front_of_house.rs из листинге 7-22.
Файл: src/lib.rs
mod front_of_house;
pub use crate::front_of_house::hosting;
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}
Затем поместим код, который был в фигурных скобках, в новый файл с именем src/front_of_house.rs, как показано в листинге 7-22. Компилятор знает, что нужно искать в этом файле, потому что он наткнулся в корневом модуле крейта на объявление модуля с именем front_of_house
.
Файл: src/front_of_house.rs
pub mod hosting {
pub fn add_to_waitlist() {}
}
Обратите внимание, что вам нужно только один раз загрузить файл с помощью объявления mod
в вашем дереве модулей. Как только компилятор узнает, что файл является частью проекта (и узнает, где в дереве модулей находится код из-за того, куда вы поместили инструкцию mod
), другие файлы в вашем проекте должны ссылаться на код загруженного файла, используя путь к месту, где он был объявлен, как описано в разделе «Пути для ссылки на элемент в дереве модулей». Другими словами, mod
— это не операция «включения», которую вы могли видеть в других языках программирования.
Далее мы извлечём модуль hosting
в его собственный файл. Процесс немного отличается, потому что hosting
является дочерним модулем для front_of_house
, а не корневого модуля. Мы поместим файл для hosting
в новый каталог, который будет назван по имени его предка в дереве модулей, в данном случае это src/front_of_house/.
Чтобы начать перенос hosting
, мы меняем src/front_of_house.rs так, чтобы он содержал только объявление модуля hosting
:
Файл: src/front_of_house.rs
pub mod hosting;
Затем мы создаём каталог src/front_of_house и файл hosting.rs, в котором будут определения, сделанные в модуле hosting
:
Файл: src/front_of_house/hosting.rs
pub fn add_to_waitlist() {}
Если вместо этого мы поместим hosting.rs в каталог src, компилятор будет думать, что код в hosting.rs это модуль hosting
, объявленный в корне крейта, а не объявленный как дочерний модуль front_of_house
. Правила компилятора для проверки какие файлы содержат код каких модулей предполагают, что каталоги и файлы точно соответствуют дереву модулей.
Альтернативные пути к файлам
До сих пор мы рассматривали наиболее идиоматические пути к файлам, используемые компилятором Rust, но Rust также поддерживает и старый стиль пути к файлу. Для модуля с именем
front_of_house
, объявленного в корневом модуле крейта, компилятор будет искать код модуля в:
- src/front_of_house.rs (что мы рассматривали)
- src/front_of_house/mod.rs (старый стиль, всё ещё поддерживаемый путь)
Для модуля с именем
hosting
, который является подмодулемfront_of_house
, компилятор будет искать код модуля в:
- src/front_of_house/hosting.rs (что мы рассматривали)
- src/front_of_house/hosting/mod.rs (старый стиль, всё ещё поддерживаемый путь)
Если вы используете оба стиля для одного и того же модуля, вы получите ошибку компилятора. Использование сочетания обоих стилей для разных модулей в одном проекте разрешено, но это может сбивать с толку людей, перемещающихся по вашему проекту.
Основным недостатком стиля, в котором используются файлы с именами mod.rs, является то, что в вашем проекте может оказаться много файлов с именами mod.rs, что может привести к путанице, если вы одновременно откроете их в редакторе.
Мы перенесли код каждого модуля в отдельный файл, а дерево модулей осталось прежним. Вызовы функций в 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(); }
Обратите внимание, что здесь мы добавили аннотацию типа. Поскольку мы не вставляем никаких значений в этот вектор, 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.
fn main() { let v = vec![1, 2, 3]; }
Поскольку мы указали начальные значения типа 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); }
Как и с любой переменной, если мы хотим изменить её значение, нам нужно сделать её изменяемой с помощью ключевого слова 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."), } }
Обратите внимание здесь на пару деталей. Мы используем значение индекса 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); }
Когда мы запускаем этот код, первая строка с &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}");
}
Компиляция этого кода приведёт к ошибке:
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
--> src/main.rs:6:5
|
4 | let first = &v[0];
| - immutable borrow occurs here
5 |
6 | v.push(6);
| ^^^^^^^^^ mutable borrow occurs here
7 |
8 | println!("The first element is: {first}");
| ------- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `collections` (bin "collections") due to 1 previous error
Код в листинге 8-6 может выглядеть так, как будто он должен работать. Почему ссылка на первый элемент должна заботиться об изменениях в конце вектора? Эта ошибка возникает из-за особенности того, как работают векторы: поскольку векторы размещают значения в памяти друг за другом, добавление нового элемента в конец вектора может потребовать выделения новой памяти и копирования старых элементов в новое пространство, если нет достаточного места, чтобы разместить все элементы друг за другом там, где в данный момент хранится вектор. В этом случае ссылка на первый элемент будет указывать на освобождённую память. Правила заимствования предотвращают попадание программ в такую ситуацию.
Примечание: Дополнительные сведения о реализации типа
Vec<T>
смотрите в разделе "The Rustonomicon".
Перебор значений в векторе
Для доступа к каждому элементу вектора по очереди, мы итерируем все элементы, вместо использования индексов для доступа к одному за раз. В листинге 8-7 показано, как использовать цикл for
для получения неизменяемых ссылок на каждый элемент в векторе значений типа i32
и их вывода.
fn main() { let v = vec![100, 32, 57]; for i in &v { println!("{i}"); } }
Мы также можем итерировать изменяемые ссылки на каждый элемент изменяемого вектора, чтобы вносить изменения во все элементы. Цикл for
в листинге 8-8 добавит 50
к каждому элементу.
fn main() { let mut v = vec![100, 32, 57]; for i in &mut v { *i += 50; } }
Чтобы изменить значение на которое ссылается изменяемая ссылка, мы должны использовать оператор разыменования ссылки *
для получения значения по ссылке в переменной 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), ]; }
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 }
Когда вектор удаляется, всё его содержимое также удаляется: удаление вектора означает и удаление значений, которые он содержит. Средство проверки заимствования гарантирует, что любые ссылки на содержимое вектора используются только тогда, когда сам вектор действителен.
Давайте перейдём к следующему типу коллекции: 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 кодировке. Когда Rustaceans говорят о "строках" то, они обычно имеют в виду типы String
или строковые срезы &str
, а не просто один из них. Хотя этот раздел в основном посвящён String
, оба типа интенсивно используются в стандартной библиотеке Rust, оба, и String
и строковые срезы, кодируются в UTF-8.
Создание новых строк
Многие из тех же операций, которые доступны Vec<T>
, доступны также в String
, потому что String
фактически реализован как обёртка вокруг вектора байтов с некоторыми дополнительными гарантиями, ограничениями и возможностями. Примером функции, которая одинаково работает с Vec<T>
и String
, является функция new
, создающая новый экземпляр типа, и показана в Листинге 8-11.
fn main() { let mut s = String::new(); }
Эта строка создаёт новую пустую строковую переменную с именем 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(); }
Эти выражения создают строку с initial contents
.
Мы также можем использовать функцию String::from
для создания String
из строкового литерала. Код листинга 8-13 является эквивалентным коду из листинга 8-12, который использует функцию to_string
:
fn main() { let s = String::from("initial contents"); }
Поскольку строки используются для очень многих вещей, можно использовать множество API для строк, предоставляющих множество возможностей. Некоторые из них могут показаться избыточными, но все они занимаются своим делом! В данном случае String::from
и to_string
делают одно и тоже, поэтому выбор зависит от стиля который вам больше импонирует.
Запомните, что строки хранятся в кодировке UTF-8, поэтому можно использовать любые правильно кодированные данные в них, как показано в листинге 8-14:
fn main() { let hello = String::from("السلام عليكم"); let hello = String::from("Dobrý den"); let hello = String::from("Hello"); let hello = String::from("שלום"); let hello = String::from("नमस्ते"); let hello = String::from("こんにちは"); let hello = String::from("안녕하세요"); let hello = String::from("你好"); let hello = String::from("Olá"); let hello = String::from("Здравствуйте"); let hello = String::from("Hola"); }
Все это допустимые String
значения.
Обновление строковых данных
Строка String
может увеличиваться в размере, а её содержимое может меняться, по аналогии как содержимое Vec<T>
при вставке в него большего количества данных. Кроме того, можно использовать оператор +
или макрос format!
для объединения значений String
.
Присоединение к строке с помощью push_str
и push
Мы можем нарастить String
используя метод push_str
который добавит в исходное значение новый строковый срез, как показано в листинге 8-15.
fn main() { let mut s = String::from("foo"); s.push_str("bar"); }
После этих двух строк кода 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}"); }
Если метод push_str
стал бы владельцем переменнойs2
, мы не смогли бы напечатать его значение в последней строке. Однако этот код работает так, как мы ожидали!
Метод push
принимает один символ в качестве параметра и добавляет его к String
. В листинге 8-17 показан код, добавляющий букву “l” к String
используя метод push
.
fn main() { let mut s = String::from("lo"); s.push('l'); }
В результате 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 }
Строка s3
будет содержать Hello, world!
. Причина того, что s1
после добавления больше недействительна и причина, по которой мы использовали ссылку на s2
имеют отношение к сигнатуре вызываемого метода при использовании оператора +
. Оператор +
использует метод add
, чья сигнатура выглядит примерно так:
fn add(self, s: &str) -> String {
В стандартной библиотеке вы увидите метод add
определённым с использованием обобщённых и связанных типов. Здесь мы видим сигнатуру с конкретными типами, заменяющими обобщённый, что происходит когда вызывается данный метод со значениями String
. Мы обсудим обобщённые типы в Главе 10. Эта сигнатура даёт нам ключ для понимания особенностей оператора +
.
Во-первых, перед s2
мы видим &
, что означает что мы складываем ссылку на вторую строку с первой строкой. Это происходит из-за параметра s
в функции add
: мы можем добавить только &str
к String
; мы не можем сложить два значения String
. Но подождите — тип &s2
это &String
, а не &str
, как определён второй параметр в add
. Так почему код в листинге 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!
значительно легче читается, а также код, сгенерированный макросом format!
, использует ссылки, а значит не забирает во владение ни один из его параметров.
Индексирование в строках
Доступ к отдельным символам в строке, при помощи ссылки на них по индексу, является допустимой и распространённой операцией во многих других языках программирования. Тем не менее, если вы попытаетесь получить доступ к частям String
, используя синтаксис индексации в Rust, то вы получите ошибку. Рассмотрим неверный код в листинге 8-19.
fn main() {
let s1 = String::from("hello");
let h = s1[0];
}
Этот код приведёт к следующей ошибке:
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
--> src/main.rs:3:16
|
3 | let h = s1[0];
| ^ string indices are ranges of `usize`
|
= help: the trait `SliceIndex<str>` is not implemented for `{integer}`, which is required by `String: Index<_>`
= note: you can use `.chars().nth()` or `.bytes().nth()`
for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
= help: the trait `SliceIndex<[_]>` is implemented for `usize`
= help: for that trait implementation, expected `[_]`, found `str`
= note: required for `String` to implement `Index<{integer}>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 previous error
Ошибка и примечание говорит, что в Rust строки не поддерживают индексацию. Но почему так? Чтобы ответить на этот вопрос, нужно обсудить то, как Rust хранит строки в памяти.
Внутреннее представление
Тип String
является оболочкой над типом Vec<u8>
. Давайте посмотрим на несколько закодированных корректным образом в UTF-8 строк из примера листинга 8-14. Начнём с этой:
fn main() { let hello = String::from("السلام عليكم"); let hello = String::from("Dobrý den"); let hello = String::from("Hello"); let hello = String::from("שלום"); let hello = String::from("नमस्ते"); let hello = String::from("こんにちは"); let hello = String::from("안녕하세요"); let hello = String::from("你好"); let hello = String::from("Olá"); let hello = String::from("Здравствуйте"); let hello = String::from("Hola"); }
В этом случае len
будет 4, что означает вектор, хранит строку "Hola" длиной 4 байта. Каждая из этих букв занимает 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` profile [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/collections`
thread 'main' panicked at src/main.rs:4:19:
byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Вы должны использовать диапазоны для создания срезов строк с осторожностью, потому что это может привести к сбою вашей программы.
Методы для перебора строк
Лучший способ работать с фрагментами строк — чётко указать, нужны ли вам символы или байты. Для отдельных скалярных значений в Юникоде используйте метод chars
. Вызов chars
у "Зд" выделяет и возвращает два значения типа char
, и вы можете выполнить итерацию по результату для доступа к каждому элементу:
#![allow(unused)] fn main() { for c in "Зд".chars() { println!("{c}"); } }
Код напечатает следующее:
З
д
Метод bytes
возвращает каждый байт, который может быть подходящим в другой предметной области:
#![allow(unused)] fn main() { for b in "Зд".bytes() { println!("{b}"); } }
Этот код выведет четыре байта, составляющих эту строку:
208
151
208
180
Но делая так, обязательно помните, что валидные скалярные Unicode значения могут состоять более чем из одного байта.
Извлечение кластеров графем из строк, как в случае с языком хинди, является сложным, поэтому эта функциональность не предусмотрена стандартной библиотекой. На crates.io есть доступные библиотеки, если Вам нужен данный функционал.
Строки не так просты
Подводя итог, становится ясно, что строки сложны. Различные языки программирования реализуют различные варианты того, как представить эту сложность для программиста. В Rust решили сделать правильную обработку данных String
поведением по умолчанию для всех программ Rust, что означает, что программисты должны заранее продумать обработку UTF-8 данных. Этот компромисс раскрывает большую сложность строк, чем в других языках программирования, но это предотвращает от необходимости обрабатывать ошибки, связанные с не-ASCII символами которые могут появиться в ходе разработки позже.
Хорошая новость состоит в том что стандартная библиотека предлагает множество функциональных возможностей, построенных на основе типов String
и &str
, чтобы помочь правильно обрабатывать эти сложные ситуации. Обязательно ознакомьтесь с документацией для полезных методов, таких как contains
для поиска в строке и replace
для замены частей строки другой строкой.
Давайте переключимся на что-то немного менее сложное: 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); }
Обратите внимание, что нужно сначала указать строку use std::collections::HashMap;
для её подключения из коллекций стандартной библиотеки. Из трёх коллекций данная является наименее используемой, поэтому она не подключается в область видимости функцией автоматического импорта (prelude). Хеш-карты также имеют меньшую поддержку со стороны стандартной библиотеки; например, нет встроенного макроса для их конструирования.
Подобно векторам, хеш-карты хранят свои данные в куче. Здесь тип HashMap
имеет в качестве типа ключей String
, а в качестве типа значений тип i32
. Как и векторы, HashMap однородны: все ключи должны иметь одинаковый тип и все значения должны иметь тоже одинаковый тип.
Доступ к данным в HashMap
Мы можем получить значение из HashMap по ключу, с помощью метода get
, как показано в листинге 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).copied().unwrap_or(0); }
Здесь score
будет иметь количество очков, связанное с командой "Blue", результат будет 10
. Метод get
возвращает Option<&V>
; если для какого-то ключа нет значения в HashMap, get
вернёт None
. Из-за такого подхода программе следует обрабатывать Option
, вызывая copied
для получения Option<i32>
вместо Option<&i32>
, затем unwrap_or
для установки score
в ноль, если scores не содержит данных по этому ключу.
Мы можем перебирать каждую пару ключ/значение в 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
Хеш-карты и владение
Для типов, которые реализуют типаж 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! }
Мы не можем использовать переменные field_name
и field_value
после того, как их значения были перемещены в HashMap вызовом метода insert
.
Если мы вставим в HashMap ссылки на значения, то они не будут перемещены в HashMap. Значения, на которые указывают ссылки, должны быть действительными хотя бы до тех пор, пока хеш-карта действительна. Мы поговорим подробнее об этих вопросах в разделе "Валидация ссылок при помощи времён жизни" главы 10.
Обновление данных в HashMap
Хотя количество ключей и значений может увеличиваться в HashMap, каждый ключ может иметь только одно значение, связанное с ним в один момент времени (обратное утверждение неверно: команды "Blue" и "Yellow" могут хранить в хеш-карте scores
одинаковое количество очков, например 10).
Когда вы хотите изменить данные в хеш-карте, необходимо решить, как обрабатывать случай, когда ключ уже имеет назначенное значение. Можно заменить старое значение новым, полностью игнорируя старое. Можно сохранить старое значение и игнорировать новое, или добавлять новое значение, если только ключ ещё не имел значения. Или можно было бы объединить старое значение и новое значение. Давайте посмотрим, как сделать каждый из вариантов!
Перезапись старых значений
Если мы вставим ключ и значение в HashMap, а затем вставим тот же ключ с новым значением, то старое значение связанное с этим ключом, будет заменено на новое. Даже несмотря на то, что код в листинге 8-23 вызывает insert
дважды, хеш-карта будет содержать только одну пару ключ/значение, потому что мы вставляем значения для одного и того же ключа - ключа команды "Blue".
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:?}"); }
Код напечатает {"Blue": 25}
. Начальное значение 10
было перезаписано.
Вставка значения только в том случае, когда ключ не имеет значения
Обычно проверяют, существует ли конкретный ключ в хеш-карте со значением, а затем предпринимаются следующие действия: если ключ существует в хеш-карте, существующее значение должно оставаться таким, какое оно есть. Если ключ не существует, то вставляют его и значение для него.
Хеш-карты имеют для этого специальный API, называемый entry
, который принимает ключ для проверки в качестве входного параметра. Возвращаемое значение метода entry
- это перечисление Entry
, с двумя вариантами: первый представляет значение, которое может существовать, а второй говорит о том, что значение отсутствует. Допустим, мы хотим проверить, имеется ли ключ и связанное с ним значение для команды "Yellow". Если хеш-карта не имеет значения для такого ключа, то мы хотим вставить значение 50. То же самое мы хотим проделать и для команды "Blue". Используем API entry
в коде листинга 8-24.
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:?}"); }
Метод or_insert
определён в Entry
так, чтобы возвращать изменяемую ссылку на соответствующее значение ключа внутри варианта перечисления Entry
, когда этот ключ существует, а если его нет, то вставлять параметр в качестве нового значения этого ключа и возвращать изменяемую ссылку на новое значение. Эта техника намного чище, чем самостоятельное написание логики и, кроме того, она более безопасна и согласуется с правилами заимствования.
При выполнении кода листинга 8-24 будет напечатано {"Yellow": 50, "Blue": 10}
. Первый вызов метода entry
вставит ключ для команды "Yellow" со значением 50, потому что для жёлтой команды ещё не имеется значения в HashMap. Второй вызов entry
не изменит хеш-карту, потому что для ключа команды "Blue" уже имеется значение 10.
Создание нового значения на основе старого значения
Другим распространённым вариантом использования хеш-карт является поиск значения по ключу, а затем обновление этого значения на основе старого значения. Например, в листинге 8-25 показан код, который подсчитывает, сколько раз определённое слово встречается в некотором тексте. Мы используем HashMap со словами в качестве ключей и увеличиваем соответствующее слову значение, чтобы отслеживать, сколько раз мы встретили это слово. Если мы впервые встретили слово, то сначала вставляем значение 0.
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:?}"); }
Этот код напечатает {"world": 2, "hello": 1, "wonderful": 1}
. Если вы увидите, что пары ключ/значение печатаются в другом порядке, то вспомните, что мы писали в секции "Доступ к данным в HashMap", что итерация по хеш-карте происходит в произвольном порядке.
Метод split_whitespace
возвращает итератор по срезам строки, разделённых пробелам, для строки text
. Метод or_insert
возвращает изменяемую ссылку (&mut V
) на значение ключа. Мы сохраняем изменяемую ссылку в переменной count
, для этого, чтобы присвоить переменной значение, необходимо произвести разыменование с помощью звёздочки (*). Изменяемая ссылка удаляется сразу же после выхода из области видимости цикла for
, поэтому все эти изменения безопасны и согласуются с правилами заимствования.
Функция хеширования
По умолчанию HashMap
использует функцию хеширования SipHash, которая может противостоять атакам класса отказ в обслуживании, Denial of Service (DoS) с использованием хеш-таблиц siphash. Это не самый быстрый из возможных алгоритмов хеширования, в данном случае производительность идёт на компромисс с обеспечением лучшей безопасности. Если после профилирования вашего кода окажется, что хеш-функция, используемая по умолчанию, очень медленная, вы можете заменить её используя другой 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 отображать стек вызовов при возникновении паники, чтобы было легче отследить источник паники.
Раскручивать стек или прерывать выполнение программы в ответ на панику?
По умолчанию, когда происходит паника, программа начинает процесс раскрутки стека, означающий в Rust проход обратно по стеку вызовов и очистку данных для каждой обнаруженной функции. Тем не менее, этот обратный проход по стеку и очистка генерируют много работы. Rust как альтернативу предоставляет вам возможность немедленного прерывания (aborting), которое завершает работу программы без очистки.
Память, которую использовала программа, должна быть очищена операционной системой. Если в вашем проекте нужно насколько это возможно сделать маленьким исполняемый файл, вы можете переключиться с варианта раскрутки стека на вариант прерывания при панике, добавьте
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` profile [unoptimized + debuginfo] target(s) in 0.25s
Running `target/debug/panic`
thread 'main' panicked at src/main.rs:2:5:
crash and burn
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]; }
Здесь мы пытаемся получить доступ к 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` profile [unoptimized + debuginfo] target(s) in 0.27s
Running `target/debug/panic`
thread 'main' panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
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/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/std/src/panicking.rs:584:5
1: core::panicking::panic_fmt
at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/panicking.rs:142:14
2: core::panicking::panic_bounds_check
at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/panicking.rs:84:5
3: <usize as core::slice::index::SliceIndex<[T]>>::index
at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/slice/index.rs:242:10
4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/slice/index.rs:18:9
5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/alloc/src/vec/mod.rs:2591:9
6: panic::main
at ./src/main.rs:4:5
7: core::ops::function::FnOnce::call_once
at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/ops/function.rs:248:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose 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"); }
File::open
возвращает значения типа Result<T, E>
. Универсальный тип T
в реализации File::open
соответствует типу успешно полученного значения, std::fs::File
, а именно дескриптору файла. Тип E
, используемый для значения в случае возникновения ошибки, - std::io::Error
. Такой возвращаемый тип означает, что вызов File::open
может быть успешным и вернуть дескриптор файла, из которого мы можем читать или в который можем писать. Также вызов функции может завершиться неудачей: например, файл может не существовать, или у нас может не быть разрешения на доступ к файлу. Функция File::open
должна иметь способ сообщить нам об успехе или неудаче и в то же время дать нам либо дескриптор файла, либо информацию об ошибке. Эту возможность как раз и предоставляет перечисление Result
.
В случае успеха File::open
значением переменной greeting_file_result
будет экземпляр Ok
, содержащий дескриптор файла. В случае неудачи значение в переменной greeting_file_result
будет экземпляром 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:?}"), }; }
Обратите внимание, что также как перечисление Option
, перечисление Result
и его варианты, входят в область видимости благодаря авто-импорту (prelude), поэтому не нужно указывать Result::
перед использованием вариантов Ok
и Err
в ветках выражения match
.
Если результатом будет Ok
, этот код вернёт значение file
из варианта Ok
, а мы затем присвоим это значение файлового дескриптора переменной greeting_file
. После match
мы можем использовать дескриптор файла для чтения или записи.
Другая ветвь match
обрабатывает случай, где мы получаем значение Err
после вызова File::open
. В этом примере мы решили вызвать макрос panic!
. Если в нашей текущей директории нет файла с именем hello.txt и мы выполним этот код, то мы увидим следующее сообщение от макроса panic!
:
$ cargo run
Compiling error-handling v0.1.0 (file:///projects/error-handling)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
Running `target/debug/error-handling`
thread 'main' panicked at src/main.rs:8:23:
Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
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:?}");
}
},
};
}
Типом значения возвращаемого функцией File::open
внутри Err
варианта является io::Error
, структура из стандартной библиотеки. Данная структура имеет метод kind
, который можно вызвать для получения значения io::ErrorKind
. Перечисление io::ErrorKind
из стандартной библиотеки имеет варианты, представляющие различные типы ошибок, которые могут появиться при выполнении операций в io
. Вариант, который мы хотим использовать, это ErrorKind::NotFound
, который даёт информацию, о том, что файл который мы пытаемся открыть ещё не существует. Итак, во второй строке мы вызываем сопоставление шаблона с переменной greeting_file_result
и попадаем в ветку с обработкой ошибки, но также у нас есть внутренняя проверка для сопоставления 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 greeting_file = 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
по документации стандартной библиотеки. Многие из методов о которых вы узнаете в документации и Главе 13 могут очистить код от больших, вложенных выражений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: Os {
code: 2, kind: NotFound, message: "No such file or directory" }',
src/main.rs:4:49
Другой метод, похожий на 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!
и заменит стандартное используемое сообщение.
Вот как это выглядит:
thread 'main' panicked at 'hello.txt should be included in this project: Os {
code: 2, kind: NotFound, message: "No such file or directory" }',
src/main.rs:5:10
В рабочем коде, большинство выбирает expect
в угоду 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), } } }
Эта функция может быть написана гораздо более коротким способом, но мы начнём с того, что многое сделаем вручную, чтобы изучить обработку ошибок; а в конце покажем более короткий способ. Давайте сначала рассмотрим тип возвращаемого значения: 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. Если File::open
завершается успешно, то дескриптор файла в переменной образца file
становится значением в изменяемой переменной username_file
и функция продолжит свою работу. В случае Err
, вместо вызова panic!
, мы используем ключевое слово return
для досрочного возврата из функции и передаём значение ошибки из File::open
, которое теперь находится в переменной образца e
, обратно в вызывающий код как значение ошибки этой функции.
Таким образом, если у нас есть файловый дескриптор в username_file
, функция создаёт новую String
в переменной username
и вызывает метод read_to_string
для файлового дескриптора в username_file
, чтобы прочитать содержимое файла в username
. Метод read_to_string
также возвращает Result
, потому что он может потерпеть неудачу, даже если File::open
завершился успешно. Поэтому нам нужен ещё один match
для обработки этого Result
: если read_to_string
завершится успешно, то наша функция сработала, и мы возвращаем имя пользователя из файла, которое теперь находится в username
, обёрнутое в Ok
. Если read_to_string
потерпит неудачу, мы возвращаем значение ошибки таким же образом, как мы возвращали значение ошибки в match
, который обрабатывал возвращаемое значение File::open
. Однако нам не нужно явно указывать return
, потому что это последнее выражение в функции.
Затем код, вызывающий этот, будет обрабатывать получение либо значения Ok
, содержащего имя пользователя, либо значения Err
, содержащего io::Error
. Вызывающий код должен решить, что делать с этими значениями. Если вызывающий код получает значение Err
, он может вызвать panic!
и завершить работу программы, использовать имя пользователя по умолчанию или найти имя пользователя, например, не в файле. У нас недостаточно информации о том, что на самом деле пытается сделать вызывающий код, поэтому мы распространяем всю информацию об успехах или ошибках вверх, чтобы она могла обрабатываться соответствующим образом.
Эта схема передачи ошибок настолько распространена в Rust, что Rust предоставляет оператор вопросительного знака ?
, чтобы облегчить эту задачу.
Сокращение для проброса ошибок: оператор ?
В листинге 9-7 показана реализация read_username_from_file
, которая имеет ту же функциональность, что и в листинге 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 mut username_file = File::open("hello.txt")?; let mut username = String::new(); username_file.read_to_string(&mut username)?; Ok(username) } }
Выражение ?
, расположенное после Result
, работает почти так же, как и те выражения match
, которые мы использовали для обработки значений Result
в листинге 9-6. Если в качестве значения Result
будет Ok
, то значение внутри Ok
будет возвращено из этого выражения, и программа продолжит работу. Если же значение представляет собой Err
, то Err
будет возвращено из всей функции, как если бы мы использовали ключевое слово return
, так что значение ошибки будет передано в вызывающий код.
Существует разница между тем, что делает выражение match
из листинга 9-6 и тем, что делает оператор ?
: значения ошибок, для которых вызван оператор ?
, проходят через функцию from
, определённую в трейте From
стандартной библиотеки, которая используется для преобразования значений из одного типа в другой. Когда оператор ?
вызывает функцию from
, полученный тип ошибки преобразуется в тип ошибки, определённый в возвращаемом типе текущей функции. Это полезно, когда функция возвращает только один тип ошибки, для описания всех возможных вариантов сбоев, даже если её отдельные компоненты могут выходить из строя по разным причинам.
Например, мы могли бы изменить функцию read_username_from_file
в листинге 9-7, чтобы возвращать пользовательский тип ошибки с именем OurError
, который мы определим. Если мы также определим impl From<io::Error> for OurError
для создания экземпляра OurError
из io::Error
, то оператор ?
, вызываемый в теле read_username_from_file
, вызовет from
и преобразует типы ошибок без необходимости добавления дополнительного кода в функцию.
В случае листинга 9-7 оператор ?
в конце вызова File::open
вернёт значение внутри Ok
в переменную username_file
. Если произойдёт ошибка, оператор ?
выполнит ранний возврат значения Err
вызывающему коду. То же самое относится к оператору ?
в конце вызова read_to_string
.
Оператор ?
позволяет избавиться от большого количества шаблонного кода и упростить реализацию этой функции. Мы могли бы даже ещё больше сократить этот код, если бы использовали цепочку вызовов методов сразу после ?
, как показано в листинге 9-8.
Файл: 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 mut username = String::new(); File::open("hello.txt")?.read_to_string(&mut username)?; Ok(username) } }
Мы перенесли создание новой String
в username
в начало функции; эта часть не изменилась. Вместо создания переменной username_file
мы соединили вызов read_to_string
непосредственно с результатом File::open("hello.txt")?
. У нас по-прежнему есть ?
в конце вызова read_to_string
, и мы по-прежнему возвращаем значение Ok
, содержащее username
, когда и File::open
и read_to_string
завершаются успешно, а не возвращают ошибки. Функциональность снова такая же, как в Листинге 9-6 и Листинге 9-7; это просто другой, более эргономичный способ её написания.
Продолжая рассматривать разные способы записи данной функции, листинг 9-9 демонстрирует способ сделать её ещё короче с помощью fs::read_to_string
.
Файл: 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") } }
Чтение файла в строку довольно распространённая операция, так что стандартная библиотека предоставляет удобную функцию fs::read_to_string
, которая открывает файл, создаёт новую String
, читает содержимое файла, размещает его в String
и возвращает её. Конечно, использование функции fs::read_to_string
не даёт возможности объяснить обработку всех ошибок, поэтому мы сначала изучили длинный способ.
Где можно использовать оператор ?
Оператор ?
может использоваться только в функциях, тип возвращаемого значения которых совместим со значением, для которого используется ?
. Это потому, что оператор ?
определён для выполнения раннего возврата значения из функции таким же образом, как и выражение match
, которое мы определили в листинге 9-6. В листинге 9-6 match
использовало значение Result
, а ответвление с ранним возвратом вернуло значение Err(e)
. Тип возвращаемого значения функции должен быть Result
, чтобы он был совместим с этим return
.
В листинге 9-10 давайте посмотрим на ошибку, которую мы получим, если воспользуемся оператором ?
в функции main
с типом возвращаемого значения, несовместимым с типом значения, для которого мы используем ?
:
Файл: src/main.rs
use std::fs::File;
fn main() {
let greeting_file = File::open("hello.txt")?;
}
Этот код открывает файл, что может привести к сбою. ?
оператор следует за значением 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() {
| --------- this function should return `Result` or `Option` to accept `?`
4 | let greeting_file = File::open("hello.txt")?;
| ^ cannot use the `?` operator in a function that returns `()`
|
= help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`
help: consider adding return type
|
3 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
4 | let greeting_file = File::open("hello.txt")?;
5 + Ok(())
|
For more information about this error, try `rustc --explain E0277`.
error: could not compile `error-handling` (bin "error-handling") due to 1 previous error
Эта ошибка указывает на то, что оператор ?
разрешено использовать только в функции, которая возвращает Result
, Option
или другой тип, реализующий FromResidual
.
Для исправления ошибки есть два варианта. Первый - изменить возвращаемый тип вашей функции так, чтобы он был совместим со значением, для которого вы используете оператор ?
, если у вас нет ограничений, препятствующих этому. Другой способ - использовать match
или один из методов Result<T, E>
для обработки Result<T, E>
любым подходящим способом.
В сообщении об ошибке также упоминалось, что ?
можно использовать и со значениями Option<T>
. Как и при использовании ?
для Result
, вы можете использовать ?
только для 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); }
Эта функция возвращает 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(())
}
Тип Box<dyn Error>
является трейт-объектом, о котором мы поговорим в разделе "Использование трейт-объектов, допускающих значения разных типов" в главе 17. Пока что вы можете считать, что Box<dyn Error>
означает "любой вид ошибки". Использование ?
для значения Result
в функции main
с типом ошибки Box<dyn Error>
разрешено, так как позволяет вернуть любое значение Err
раньше времени. Даже если тело этой функции main
будет возвращать только ошибки типа std::io::Error
, указав Box<dyn Error>
, эта сигнатура останется корректной, даже если в тело main
будет добавлен код, возвращающий другие ошибки.
Когда main
функция возвращает Result<(), E>
, исполняемый файл завершится со значением 0
, если main
вернёт Ok(())
, и выйдет с ненулевым значением, если main
вернёт значение Err
. Исполняемые файлы, написанные на C, при выходе возвращают целые числа: успешно завершённые программы возвращают целое число 0
, а программы с ошибкой возвращают целое число, отличное от 0
. Rust также возвращает целые числа из исполняемых файлов, чтобы быть совместимым с этим соглашением.
Функция main
может возвращать любые типы, реализующие трейт std::process::Termination
, в которых имеется функция report
, возвращающая ExitCode
. Обратитесь к документации стандартной библиотеки за дополнительной информацией о порядке реализации трейта Termination
для ваших собственных типов.
Теперь, когда мы обсудили детали вызова panic!
или возврата Result
, давайте вернёмся к тому, как решить, какой из случаев подходит для какой ситуации.
panic!
или не panic!
Итак, как принимается решение о том, когда следует вызывать panic!
, а когда вернуть Result
? При панике код не имеет возможности восстановить своё выполнение. Можно было бы вызывать panic!
для любой ошибочной ситуации, независимо от того, имеется ли способ восстановления или нет, но с другой стороны, вы принимаете решение от имени вызывающего вас кода, что ситуация необратима. Когда вы возвращаете значение Result
, вы делегируете принятие решения вызывающему коду. Вызывающий код может попытаться выполнить восстановление способом, который подходит в данной ситуации, или же он может решить, что из ошибки в Err
нельзя восстановиться и вызовет panic!
, превратив вашу исправимую ошибку в неисправимую. Поэтому возвращение Result
является хорошим выбором по умолчанию для функции, которая может дать сбой.
В таких ситуация как примеры, прототипы и тесты, более уместно писать код, который паникует вместо возвращения Result
. Давайте рассмотрим почему, а затем мы обсудим ситуации, в которых компилятор не может доказать, что ошибка невозможна, но вы, как человек, можете это сделать. Глава будет заканчиваться некоторыми общими руководящими принципами о том, как решить, стоит ли паниковать в коде библиотеки.
Примеры, прототипирование и тесты
Когда вы пишете пример, иллюстрирующий некоторую концепцию, наличие хорошего кода обработки ошибок может сделать пример менее понятным. Понятно, что в примерах вызов метода unwrap
, который может привести к панике, является лишь обозначением способа обработки ошибок в приложении, который может отличаться в зависимости от того, что делает остальная часть кода.
Точно так же методы unwrap
и expect
являются очень удобными при создании прототипа, прежде чем вы будете готовы решить, как обрабатывать ошибки. Они оставляют чёткие маркеры в коде до момента, когда вы будете готовы сделать программу более надёжной.
Если в тесте происходит сбой при вызове метода, то вы бы хотели, чтобы весь тест не прошёл, даже если этот метод не является тестируемой функциональностью. Поскольку вызов panic!
это способ, которым тест помечается как провалившийся, использование unwrap
или expect
- именно то, что нужно.
Случаи, в которых у вас больше информации, чем у компилятора
Также было бы целесообразно вызывать unwrap
или expect
когда у вас есть какая-то другая логика, которая гарантирует, что Result
будет иметь значение Ok
, но вашу логику не понимает компилятор. У вас по-прежнему будет значение Result
которое нужно обработать: любая операция, которую вы вызываете, все ещё имеет возможность неудачи в целом, хотя это логически невозможно в вашей конкретной ситуации. Если, проверяя код вручную, вы можете убедиться, что никогда не будет вариант с Err
, то вполне допустимо вызывать unwrap
, а ещё лучше задокументировать причину, по которой, по вашему мнению, у вас никогда не будет варианта Err
в тексте expect
. Вот пример:
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-адресом, поэтому здесь допустимо использование expect
. Однако наличие жёстко закодированной допустимой строки не меняет тип возвращаемого значения метода parse
: мы все ещё получаем значение Result
и компилятор все также заставляет нас обращаться с Result
так, будто возможен вариант Err
, потому что компилятор недостаточно умён, чтобы увидеть, что эта строка всегда действительный IP-адрес. Если строка IP-адреса пришла от пользователя, то она не является жёстко запрограммированной в программе и, следовательно, может привести к ошибке, мы определённо хотели бы обработать Result
более надёжным способом. Упоминание предположения о том, что этот IP-адрес жёстко закодирован, побудит нас изменить expect
для лучшей обработки ошибок, если в будущем нам потребуется вместо этого получить IP-адрес из какого-либо другого источника.
Руководство по обработке ошибок
Желательно, чтобы код паниковал, если он может оказаться в некорректном состоянии. В этом контексте некорректное состояние это когда некоторое допущение, гарантия, контракт или инвариант были нарушены. Например, когда недопустимые, противоречивые или пропущенные значения передаются в ваш код - плюс один или несколько пунктов из следующего перечисленного в списке:
- Некорректное состояние — это что-то неожиданное, отличается от того, что может происходить время от времени, например, когда пользователь вводит данные в неправильном формате.
- Ваш код после этой точки должен полагаться на то, что он не находится в некорректном состоянии, вместо проверок наличия проблемы на каждом этапе.
- Нет хорошего способа закодировать данную информацию в типах, которые вы используете. Мы рассмотрим пример того, что мы имеем в виду в разделе “Кодирование состояний и поведения на основе типов” главы 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() { }
Сначала мы определяем структуру с именем 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), вариации обобщённых типов, которые дают компилятору информацию о том, как сроки жизни ссылок относятся друг к другу. Времена жизни позволяют нам указать дополнительную информацию об "одолженных" (borrowed) значениях, которая позволит компилятору удостовериться в корректности используемых ссылок в тех ситуациях, когда компилятор не может сделать это автоматически.
Удаление дублирования кода с помощью выделения общей функциональности
В обобщениях мы можем заменить конкретный тип на "заполнитель" (placeholder), обозначающую несколько типов, что позволяет удалить дублирующийся код. Прежде чем углубляться в синтаксис обобщённых типов, давайте сначала посмотрим, как удалить дублирование, не задействуя универсальные типы, путём извлечения функции, которая заменяет определённые значения заполнителем, представляющим несколько значений. Затем мы применим ту же технику для извлечения универсальной функции! Изучив, как распознать дублированный код, который можно извлечь в функцию, вы начнёте распознавать дублированный код, который может использовать обобщённые типы.
Начнём с короткой программы в листинге 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); }
Сохраним список целых чисел в переменной 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-3 мы извлекаем код, который находит наибольшее число, в функцию с именем largest
. Затем мы вызываем функцию, чтобы найти наибольшее число в двух списках из листинга 10-2. Мы также можем использовать эту функцию для любого другого списка значений i32
, который может встретиться позже.
Файл: 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); }
Функция largest
имеет параметр с именем list
, который представляет любой срез значений типа i32
, которые мы можем передать в неё. В результате вызова функции, код выполнится с конкретными, переданными в неё значениями.
Итак, вот шаги выполненные для изменения кода из листинга 10-2 в листинг 10-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'); }
Функция largest_i32
уже встречалась нам: мы извлекли её в листинге 10-3, когда боролись с дублированием кода — она находит наибольшее значение типа i32
в срезе. Функция largest_char
находит самое большое значение типа char
в срезе. Тело у этих функций одинаковое, поэтому давайте избавимся от дублируемого кода, используя параметр обобщённого типа в одной функции.
Для параметризации типов данных в новой объявляемой функции нам нужно дать имя обобщённому типу — так же, как мы это делаем для аргументов функций. Можно использовать любой идентификатор для имени параметра типа, но мы будем использовать T
, потому что по соглашению имена параметров в Rust должны быть короткими (обычно длиной в один символ), а именование типов в Rust делается в нотации UpperCamelCase. Сокращение слова «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}");
}
Если мы скомпилируем программу сейчас, мы получим следующую ошибку:
$ 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` (bin "chapter10") due to 1 previous error
В подсказке упоминается std::cmp::PartialOrd
, который является типажом. Мы поговорим про типажи в следующем разделе. Сейчас ошибка в функции largest
указывает, что функция не будет работать для всех возможных типов T
. Так как мы хотим сравнивать значения типа T
в теле функции, мы можем использовать только те типы, данные которых можно упорядочить: можем упорядочить — значит, можем и сравнить. Чтобы можно было задействовать сравнения, стандартная библиотека имеет типаж std::cmp::PartialOrd
, который вы можете реализовать для типов (смотрите дополнение С для большей информации про данный типаж). Следуя совету в сообщении компилятора, ограничим тип T
теми вариантами, которые поддерживают типаж PartialOrd
, и тогда пример успешно скомпилируется, так как стандартная библиотека реализует PartialOrd
как для типа i32
, так и для типа char
.
В определении структур
Мы также можем определить структуры, использующие обобщённые типы в одном или нескольких своих полях, с помощью синтаксиса <>
. Листинг 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 }; }
Синтаксис использования обобщённых типов в определении структуры очень похож на синтаксис в определении функции. Сначала мы объявляем имена типов параметров внутри треугольных скобок сразу после названия структуры. Затем мы можем использовать обобщённые типы в определении структуры в тех местах, где ранее мы указывали бы конкретные типы.
Так как мы используем только один обобщённый тип данных для определения структуры 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 };
}
В этом примере, когда мы присваиваем целочисленное значение 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` (bin "chapter10") due to 1 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 }; }
Теперь разрешены все показанные экземпляры типа 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-3, где T
заполнялось типом std::fs::File
, если файл был открыт успешно, либо E
заполнялось типом std::io::Error
, если при открытии файла возникали какие-либо проблемы.
Если вы встречаете в коде ситуации, когда несколько определений структур или перечислений отличаются только типами содержащихся в них значений, вы можете устранить дублирование, используя обобщённые типы.
В определении методов
Мы можем реализовать методы для структур и перечислений (как мы делали в главе 5) и в определениях этих методов также использовать обобщённые типы. В листинге 10-9 показана структура Point<T>
, которую мы определили в листинге 10-6, с добавленным для неё методом x
.
Файл: 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()); }
Здесь мы определили метод с именем x
у структуры Point<T>
, который возвращает ссылку на данные в поле x
.
Обратите внимание, что мы должны объявить T
сразу после impl
. В этом случае мы можем использовать T
для указания на то, что реализуем метод для типа Point<T>
. Объявив T
универсальным типом сразу после impl
, Rust может определить, что тип в угловых скобках в Point
является универсальным, а не конкретным типом. Мы могли бы выбрать другое имя для этого обобщённого параметра, отличное от имени, использованного в определении структуры, но обычно используют одно и то же имя. Методы, написанные внутри раздела impl
, который использует обобщённый тип, будут определены для любого экземпляра типа, независимо от того, какой конкретный тип в конечном итоге будет подставлен вместо этого обобщённого.
Мы можем также указать ограничения, какие обобщённые типы разрешено использовать при определении методов. Например, мы могли бы реализовать методы только для экземпляров типа 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()); }
Этот код означает, что тип 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); }
В функции main
мы определили тип Point
, который имеет тип i32
для x
(со значением 5
) и тип f64
для y
(со значением 10.4
). Переменная p2
является структурой Point
, которая имеет строковый срез для x
(со значением «Hello»
) и char
для y
(со значением c
). Вызов mixup
на p1
с аргументом p2
создаст для нас экземпляр структуры p3
, который будет иметь тип i32
для x
(потому что x
взят из p1
). Переменная p3
будет иметь тип char
для y
(потому что y
взят из p2
). Вызов макроса println!
выведет p3.x = 5, p3.y = c
.
Цель этого примера — продемонстрировать ситуацию, в которой некоторые обобщённые параметры объявлены с помощью impl
, а некоторые объявлены в определении метода. Здесь обобщённые параметры X1
и Y1
объявляются после impl
, потому что они относятся к определению структуры. Обобщённые параметры X2
и Y2
объявляются после fn mixup
, так как они относятся только к методу.
Производительность кода, использующего обобщённые типы
Вы могли бы задаться вопросом, возникают ли какие-нибудь дополнительные издержки при использовании параметров обобщённого типа. Хорошая новость в том, что при использовании обобщённых типов ваша программа работает ничуть ни медленнее, чем если бы она работала с использованием конкретных типов.
В 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>
в два определения, специализированные для i32
и f64
, тем самым заменяя обобщённое определение конкретными.
Мономорфизированная версия кода выглядит примерно так (компилятор использует имена, отличные от тех, которые мы используем здесь для иллюстрации):
Файл: 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); }
Обобщённое Option<T>
заменяется конкретными определениями, созданными компилятором. Поскольку Rust компилирует обобщённый код в код, определяющий тип в каждом экземпляре, мы не платим за использование обобщённых типов во время выполнения. Когда код запускается, он работает точно так же, как если бы мы продублировали каждое определение вручную. Процесс мономорфизации делает обобщённые типы Rust чрезвычайно эффективными во время выполнения.
Типажи: определение общего поведения
Типаж сообщает компилятору Rust о функциональности, которой обладает определённый тип и которой он может поделиться с другими типами. Можно использовать типажи, чтобы определять общее поведение абстрактным способом. Мы можем использовать ограничение типажа (trait bounds) чтобы указать, что общим типом может быть любой тип, который имеет определённое поведение.
Примечание: Типажи похожи на функциональность часто называемую интерфейсами в других языках программирования, хотя и с некоторыми отличиями.
Определение типажа
Поведение типа определяется теми методами, которые мы можем вызвать у данного типа. Различные типы разделяют одинаковое поведение, если мы можем вызвать одни и те же методы у этих типов. Определение типажей - это способ сгруппировать сигнатуры методов вместе для того, чтобы описать общее поведение, необходимое для достижения определённой цели.
Например, пусть есть несколько структур, которые имеют различный тип и различный размер текста: структура NewsArticle
, которая содержит новость, напечатанную в каком-то месте мира; структура Tweet
, которая содержит 280 символьную строку твита и мета-данные, обозначающие является ли твит новым или ответом на другой твит.
Мы хотим создать крейт библиотеки медиа-агрегатора aggregator
, которая может отображать сводку данных сохранённых в экземплярах структур NewsArticle
или Tweet
. Чтобы этого достичь, нам необходимо иметь возможность для каждой структуры получить короткую сводку на основе имеющихся данных, и для этого мы запросим сводку вызвав метод summarize
. Листинг 10-12 показывает определение типажа Summary
, который выражает это поведение.
Файл: src/lib.rs
pub trait Summary {
fn summarize(&self) -> String;
}
Здесь мы объявляем типаж с использованием ключевого слова trait
, а затем его название, которым в нашем случае является Summary
. Также мы объявляем крейт как pub
что позволяет крейтам, зависящим от нашего крейта, тоже использовать наш крейт, что мы увидим в последующих примерах. Внутри фигурных скобок объявляются сигнатуры методов, которые описывают поведения типов, реализующих данный типаж, в данном случае поведение определяется только одной сигнатурой метода 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)
}
}
Реализация типажа у типа аналогична реализации обычных методов. Разница в том что после impl
мы ставим имя типажа, который мы хотим реализовать, затем используем ключевое слово for
, а затем указываем имя типа, для которого мы хотим сделать реализацию типажа. Внутри блока impl
мы помещаем сигнатуру метода объявленную в типаже. Вместо добавления точки с запятой в конце, после каждой сигнатуры используются фигурные скобки и тело метода заполняется конкретным поведением, которое мы хотим получить у методов типажа для конкретного типа.
Теперь когда библиотека реализовала типаж Summary
для NewsArticle
и Tweet
, программисты использующие крейт могут вызывать методы типажа у экземпляров типов NewsArticle
и Tweet
точно так же как если бы это были обычные методы. Единственное отличие состоит в том, что программист должен ввести типаж в область видимости точно так же как и типы. Здесь пример того как бинарный крейт может использовать наш aggregator
:
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
.
Другие крейты, которые зависят от aggregator
, тоже могу включить типаж Summary
в область видимости для реализации Summary
в их собственных типах. Одно ограничение, на которое следует обратить внимание, заключается в том, что мы можем реализовать типаж для типа только в том случае, если хотя бы один из типажей типа является локальным для нашего крейта. Например, мы можем реализовать стандартный библиотечный типаж Display
на собственном типе Tweet
как часть функциональности нашего крейта aggregator
потому что тип Tweet
является локальным для крейта aggregator
. Также мы можем реализовать Summary
для Vec<T>
в нашем крейте aggregator
, потому что типаж Summary
является локальным для нашего крейта aggregator
.
Но мы не можем реализовать внешние типажи для внешних типов. Например, мы не можем реализовать типаж Display
для Vec<T>
внутри нашего крейта aggregator
, потому что Display
и Vec<T>
оба определены в стандартной библиотеке а не локально в нашем крейте aggregator
. Это ограничение является частью свойства называемого согласованность (coherence), а ещё точнее сиротское правило (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)
}
}
Для использования реализации по умолчанию при создании сводки у экземпляров 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...)
.
Создание реализации по умолчанию не требует от нас изменений чего-либо в реализации 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...)
.
Обратите внимание, что невозможно вызвать реализацию по умолчанию из переопределённой реализации того же метода.
Типажи как параметры
Теперь, когда вы знаете, как определять и реализовывать типажи, можно изучить, как использовать типажи, чтобы определить функции, которые принимают много различных типов. Мы будем использовать типаж Summary
, реализованный для типов NewsArticle
и Tweet
в листинге 10-13, чтобы определить функцию 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
работает для простых случаев, но на самом деле является синтаксическим сахаром для более длинной формы, которая называется ограничением типажа (trait bound); это выглядит так:
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) {
Использовать impl Trait
удобнее если мы хотим разрешить функции иметь разные типы для item1
и item2
(но оба типа должны реализовывать Summary
). Если же мы хотим заставить оба параметра иметь один и тот же тип, то мы должны использовать ограничение типажа так:
pub fn notify<T: Summary>(item1: &T, item2: &T) {
Обобщённый тип T
указан для типов параметров item1
и item2
и ограничивает функцию так, что конкретные значения типов переданные аргументами для item1
и item2
должны быть одинаковыми.
Задание нескольких границ типажей с помощью синтаксиса +
Также можно указать более одного ограничения типажа. Допустим, мы хотели бы чтобы notify
использовал как форматирование вывода так и summarize
для параметра item
:
тогда мы указываем что в 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,
{
unimplemented!()
}
Сигнатура этой функции менее загромождена: название функции, список параметров, и возвращаемый тип находятся рядом, а сигнатура не содержит в себе множество ограничений типажа.
Возврат значений типа реализующего определённый типаж
Также можно использовать синтаксис 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.
Использование ограничений типажа для условной реализации методов
Используя ограничение типажа с блоком impl
, который использует параметры обобщённого типа, можно реализовать методы условно, для тех типов, которые реализуют указанный типаж. Например, тип Pair<T>
в листинге 10-15 всегда реализует функцию new
для возврата нового экземпляра Pair<T>
(вспомните раздел “Определение методов” Главы 5 где Self
является псевдонимом типа для типа блока impl
, который в данном случае является Pair<T>
). Но в следующем блоке impl
тип Pair<T>
реализует метод cmp_display
только если его внутренний тип T
реализует типаж PartialOrd
(позволяющий сравнивать) и типаж Display
(позволяющий выводить на печать).
Файл: src/lib.rs
use std::fmt::Display;
struct Pair<T> {
x: T,
y: T,
}
impl<T> Pair<T> {
fn new(x: T, y: T) -> Self {
Self { x, y }
}
}
impl<T: Display + PartialOrd> Pair<T> {
fn cmp_display(&self) {
if self.x >= self.y {
println!("The largest member is x = {}", self.x);
} else {
println!("The largest member is y = {}", self.y);
}
}
}
Мы также можем условно реализовать типаж для любого типа, который реализует другой типаж. Реализации типажа для любого типа, который удовлетворяет ограничениям типажа, называются общими реализациями и широко используются в стандартной библиотеке 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 перемещает эти ошибки на время компиляции, поэтому мы вынуждены исправить проблемы, прежде чем наш код начнёт работать. Кроме того, мы не должны писать код, который проверяет своё поведение во время выполнения, потому что это уже проверено во время компиляции. Это повышает производительность без необходимости отказываться от гибкости обобщённых типов.
Валидация ссылок при помощи времён жизни
Сроки (времена) жизни - ещё один вид обобщений, с которыми мы уже встречались. Если раньше мы использовали обобщения, чтобы убедиться, что тип обладает нужным нам поведением, теперь мы будем использовать сроки жизни для того, чтобы быть уверенными, что ссылки действительны как минимум столько времени в процессе исполнения программы, сколько нам требуется.
В разделе "Ссылки и заимствование" главы 4, мы кое о чём умолчали: у каждой ссылки в Rust есть своё время жизни — область кода, на протяжении которого данная ссылка действительна (valid). В большинстве случаев сроки жизни выводятся неявно — так же, как у типов (нам требуется явно объявлять типы лишь в тех случаях, когда при автоматическом выведении типа возможны варианты). Точно так же мы должны явно объявлять сроки жизни тех ссылок, для которых времена жизни могут быть определены компилятором по-разному. Rust требует от нас объявлять взаимосвязи посредством обобщённых параметров сроков жизни, чтобы убедиться в том, что во время исполнения все действующие ссылки будут корректными.
Аннотирование времени жизни — это концепция, отсутствующая в большинстве других языков программирования, так что она может показаться незнакомой. Хотя в этой главе мы не будем рассматривать времена жизни во всех деталях, тем не менее, мы обсудим основные ситуации, в которых вы можете столкнуться с синтаксисом времени жизни, что позволит вам получше ознакомиться с этой концепцией.
Времена жизни предотвращают появление "повисших" ссылок
Основное предназначение сроков жизни — предотвращать появление так называемых "повисших ссылок" (dangling references), из-за которых программа обращается не к тем данным, к которым она собиралась обратиться. Рассмотрим программу из листинга 10-16, имеющую внешнюю и внутреннюю области видимости.
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("r: {r}");
}
Примечание: примеры в листингах 10-16, 10-17 и 10-23 объявляют переменные без указания их начального значения, поэтому имя переменной существует во внешней области видимости. На первый взгляд может показаться, что это противоречит отсутствию в Rust нулевых (null) значений. Однако, если мы попытаемся использовать переменную, прежде чем присвоить ей значение, мы получим ошибку компиляции, которая показывает, что Rust действительно не разрешает нулевые (null) значения.
Внешняя область видимости объявляет переменную с именем r
без начального значения, а внутренняя область объявляет переменную с именем x
с начальным значением 5
. Во внутренней области мы пытаемся установить значение r
как ссылку на x
. Затем внутренняя область видимости заканчивается и мы пытаемся напечатать значение из r
. Этот код не будет скомпилирован, потому что значение на которое ссылается r
исчезает из области видимости, прежде чем мы попробуем использовать его. Вот сообщение об ошибке:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `x` does not live long enough
--> src/main.rs:6:13
|
5 | let x = 5;
| - binding `x` declared here
6 | r = &x;
| ^^ borrowed value does not live long enough
7 | }
| - `x` dropped here while still borrowed
8 |
9 | println!("r: {r}");
| --- borrow later used here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
Переменная x
«не живёт достаточно долго». Причина в том, что x
выйдет из области видимости, когда эта внутренняя область закончится в строке 7. Но r
все ещё является действительной во внешней области видимости; поскольку её охват больше, мы говорим, что она «живёт дольше». Если бы Rust позволил такому коду работать, то переменная r
смогла бы ссылаться на память, которая уже была освобождена (в тот момент, когда x
вышла из внутренней области видимости), и всё что мы попытались бы сделать с r
работало бы неправильно. Как же Rust определяет, что этот код некорректен? Он использует для этого анализатор заимствований (borrow checker).
Анализатор заимствований
Компилятор Rust имеет в своём составе анализатор заимствований, который сравнивает области видимости для определения, являются ли все заимствования действительными. В листинге 10-17 показан тот же код, что и в листинге 10-16, но с аннотациями, показывающими времена жизни переменных.
fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {r}"); // |
} // ---------+
Здесь мы описали время жизни для r
с помощью 'a
и время жизни x
с помощью 'b
. Как видите, время жизни 'b
внутреннего блока гораздо меньше, чем время жизни 'a
внешнего блока. Во время компиляции Rust сравнивает продолжительность двух времён жизни и видит, что r
имеет время жизни 'a
, но ссылается на память со временем жизни 'b
. Программа отклоняется, потому что 'b
короче, чем 'a
: объект ссылки не живёт так же долго, как сама ссылка.
Листинг 10-18 исправляет код, чтобы в нём не было повисшей ссылки, и компилируется без ошибок.
fn main() { let x = 5; // ----------+-- 'b // | let r = &x; // --+-- 'a | // | | println!("r: {r}"); // | | // --+ | } // ----------+
Здесь переменная x
имеет время жизни 'b
, которое больше, чем время жизни 'a
. Это означает, что переменная r
может ссылаться на переменную x
потому что Rust знает, что ссылка в переменной r
будет всегда действительной до тех пор, пока переменная x
является валидной.
После того, как мы на примерах рассмотрели времена жизни ссылок и обсудили как Rust их анализирует, давайте поговорим об обобщённых временах жизни входных параметров и возвращаемых значений функций.
Обобщённые времена жизни в функциях
Напишем функцию, которая возвращает более длинный из двух срезов строки. Эта функция принимает два среза строки и возвращает один срез строки. После того как мы реализовали функцию longest
, код в листинге 10-19 должен вывести 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}");
}
Обратите внимание, что мы хотим чтобы функция принимала строковые срезы, которые являются ссылками, а не строки, потому что мы не хотим, чтобы функция longest
забирала во владение свои параметры. Обратитесь к разделу "Строковые срезы как параметры" Главы 4 для более подробного обсуждения того, почему параметры используемые в листинге 10-19 выбраны именно таким образом.
Если мы попробуем реализовать функцию longest
так, как это показано в листинге 10-20, программа не скомпилируется:
Файл: 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(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
Вместо этого мы получим следующую ошибку, говорящую о временах жизни:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0106]: missing lifetime specifier
--> src/main.rs:9:33
|
9 | fn longest(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
|
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++
For more information about this error, try `rustc --explain E0106`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
Текст ошибки показывает, что возвращаемому типу нужен обобщённый параметр времени жизни, потому что Rust не может определить, относится ли возвращаемая ссылка к x
или к y
. На самом деле, мы тоже не знаем, потому что блок if
в теле функции возвращает ссылку на x
, а блок else
возвращает ссылку на y
!
Когда мы определяем эту функцию, мы не знаем конкретных значений, которые будут в неё передаваться. Поэтому мы не знаем какая из ветвей оператора if
или else
будет выполнена. Мы также не знаем конкретных времён жизни ссылок, которые будут переданы в функцию, поэтому мы не можем посмотреть на их области видимости, как мы делали в примерах 10-17 и 10-18, чтобы определить, будет ли возвращаемая нами ссылка корректной во всех случаях. Анализатор заимствований также не может этого определить, потому что он не знает как времена жизни переменных x
и y
соотносятся с временем жизни возвращаемого значения. Чтобы исправить эту ошибку, мы добавим обобщённый параметр времени жизни, который определит отношения между ссылками таким образом, чтобы анализатор заимствований мог провести свой анализ.
Синтаксис аннотации времени жизни
Аннотации времени жизни не меняют срок, как долго живёт та или иная ссылка. Они скорее описывают, как соотносятся между собой времена жизни нескольких ссылок, не влияя на само время жизни. Точно так же, как функции могут принимать любой тип, когда в сигнатуре указан параметр обобщённого типа, функции могут принимать ссылки с любым временем жизни, указанным с помощью параметра обобщённого времени жизни.
Аннотации времени жизни имеют немного необычный синтаксис: имена параметров времени жизни должны начинаться с апострофа ('
), пишутся маленькими буквами, и обычно очень короткие, как и имена обобщённых типов. Большинство людей использует имя 'a
в качестве первой аннотации времени жизни. Аннотации параметров времени жизни следуют после символа &
и отделяются пробелом от названия ссылочного типа.
Приведём несколько примеров: у нас есть ссылка на i32
без указания времени жизни, ссылка на i32
, с временем жизни имеющим имя 'a
и изменяемая ссылка на i32
, которая также имеет время жизни 'a
.
&i32 // a reference
&'a i32 // a reference with an explicit lifetime
&'a mut i32 // a mutable reference with an explicit lifetime
Одна аннотация времени жизни сама по себе не имеет большого значения, поскольку аннотации предназначены для того, чтобы проинформировать Rust о том, как времена жизни нескольких ссылок соотносятся между собой. Давайте рассмотрим, как аннотации времени жизни связаны друг с другом в контексте функции longest
.
Аннотации времени жизни в сигнатурах функций
Чтобы использовать аннотации времени жизни в сигнатурах функций, нам нужно объявить параметры обобщённого времени жизни внутри угловых скобок между именем функции и списком параметров, как мы это делали с параметрами обобщённого типа .
Мы хотим, чтобы сигнатура отражала следующее ограничение: возвращаемая ссылка будет действительна до тех пор, пока валидны оба параметра. Это связь между временами жизни параметров и возвращаемого значения. Мы назовём это время жизни 'a
, а затем добавим его к каждой ссылке, как показано в листинге 10-21.
Файл: 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 } }
Этот код должен компилироваться и давать желаемый результат, когда мы вызовем его в функции main
листинга 10-19.
Сигнатура функции теперь сообщает 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-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 } }
В этом примере переменная string1
действительна до конца внешней области, string2
действует до конца внутренней области видимости и result
ссылается на что-то, что является действительным до конца внутренней области видимости. Запустите этот код, и вы увидите что анализатор заимствований разрешает такой код; он скомпилирует и напечатает The longest string is long string is long
.
Теперь, давайте попробуем пример, который показывает, что время жизни ссылки result
должно быть меньшим временем жизни одного из двух аргументов. Мы переместим объявление переменной result
за пределы внутренней области видимости, но оставим присвоение значения переменной result
в области видимости string2
. Затем мы переместим println!
, который использует result
за пределы внутренней области видимости, после того как внутренняя область видимости закончилась. Код в листинге 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
}
}
При попытке скомпилировать этот код, мы получим такую ошибку:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `string2` does not live long enough
--> src/main.rs:6:44
|
5 | let string2 = String::from("xyz");
| ------- binding `string2` declared here
6 | result = longest(string1.as_str(), string2.as_str());
| ^^^^^^^ borrowed value does not live long enough
7 | }
| - `string2` dropped here while still borrowed
8 | println!("The longest string is {result}");
| -------- borrow later used here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
Эта ошибка говорит о том, что если мы хотим использовать result
в инструкции println!
, переменная string2
должна бы быть действительной до конца внешней области видимости. Rust знает об этом, потому что мы аннотировали параметры функции и её возвращаемое значение одинаковым временем жизни 'a
.
Будучи людьми, мы можем посмотреть на этот код и увидеть, что string1
длиннее, чем string2
и, следовательно, result
будет содержать ссылку на string1
. Поскольку string1
ещё не вышла из области видимости, ссылка на string1
будет все ещё действительной в инструкции println!
. Однако компилятор не видит, что ссылка в этом случае валидна. Мы сказали Rust, что время жизни ссылки, возвращаемой из функции longest
, равняется меньшему из времён жизни переданных в неё ссылок. Таким образом, анализатор заимствований запрещает код в листинге 10-23, как возможно имеющий недействительную ссылку.
Попробуйте провести больше экспериментов с различными значениями и временами жизни ссылок, передаваемых в функцию 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 value referencing local variable `result`
--> src/main.rs:11:5
|
11 | result.as_str()
| ------^^^^^^^^^
| |
| returns a value referencing data owned by the current function
| `result` is borrowed here
For more information about this error, try `rustc --explain E0515`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
Проблема заключается в том, что result
выходит за область видимости и очищается в конце функции longest
. Мы также пытаемся вернуть ссылку на result
из функции. Мы не можем указать параметры времени жизни, которые могли бы изменить недействительную ссылку, а Rust не позволит нам создать недействительную ссылку. В этом случае лучшим решением будет вернуть владеющий тип данных, а не ссылку: в этом случае вызывающая функция будет нести ответственность за очистку полученного ею значения.
В конечном итоге, синтаксис времён жизни реализует связывание времён жизни различных аргументов и возвращаемых значений функций. Описывая времена жизни, мы даём Rust достаточно информации, чтобы разрешить безопасные операции с памятью и запретить операции, которые могли бы создать недействительные ссылки или иным способом нарушить безопасность памяти.
Определение времён жизни при объявлении структур
До сих пор мы объявляли структуры, которые всегда содержали владеющие типы данных. Структуры могут содержать и ссылки, но при этом необходимо добавить аннотацию времени жизни для каждой ссылки в определении структуры. Листинг 10-24 описывает структуру ImportantExcerpt
, содержащую срез строки:
Файл: 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().unwrap(); let i = ImportantExcerpt { part: first_sentence, }; }
У структуры имеется одно поле part
, хранящее срез строки, который сам по себе является ссылкой. Как и в случае с обобщёнными типами данных, мы объявляем имя обобщённого параметра времени жизни внутри угловых скобок после имени структуры, чтобы иметь возможность использовать его внутри определения структуры. Данная аннотация означает, что экземпляр ImportantExcerpt
не может пережить ссылку, которую он содержит в своём поле part
.
Функция main
здесь создаёт экземпляр структуры ImportantExcerpt
, который содержит ссылку на первое предложение типа String
принадлежащее переменной novel
. Данные в novel
существуют до создания экземпляра ImportantExcerpt
. Кроме того, novel
не выходит из области видимости до тех пор, пока ImportantExcerpt
не выйдет за область видимости, поэтому ссылка в внутри экземпляра ImportantExcerpt
является действительной.
Правила неявного выведения времени жизни
Вы изучили, что у каждой ссылки есть время жизни и что нужно указывать параметры времени жизни для функций или структур, которые используют ссылки. Однако в Главе 4 у нас была функция в листинге 4-9, которая затем снова показана в листинге 10-25, в которой код скомпилировался без аннотаций времени жизни.
Файл: src/lib.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 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); }
Причина, по которой этот код компилируется — историческая. В ранних (до-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-25. Сигнатура этой функции начинается без объявления времён жизни ссылок:
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-20:
fn longest(x: &str, y: &str) -> &str {
Применим первое правило: каждому параметру назначается собственное время жизни. На этот раз у функции есть два параметра, поэтому есть два времени жизни:
fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {
Можно заметить, что второе правило здесь не применимо, так как в сигнатуре указано больше одного входного параметра времени жизни. Третье правило также не применимо, так как longest
— функция, а не метод, следовательно, в ней нет параметра self
. Итак, мы прошли все три правила, но так и не смогли вычислить время жизни выходного параметра. Поэтому мы и получили ошибку при попытке скомпилировать код листинга 10-20: компилятор работал по правилам неявного выведения времён жизни, но не мог выяснить все времена жизни ссылок в сигнатуре.
Так как третье правило применяется только к методам, далее мы рассмотрим времена жизни в этом контексте, чтобы понять, почему нам часто не требуется аннотировать времена жизни в сигнатурах методов.
Аннотация времён жизни в определении методов
Когда мы реализуем методы для структур с временами жизни, мы используем тот же синтаксис, который применялся для аннотаций обобщённых типов данных на листинге 10-11. Место, где мы объявляем и используем времена жизни, зависит от того, с чем они связаны — с полями структуры, либо с аргументами методов и возвращаемыми значениями.
Имена параметров времени жизни для полей структур всегда описываются после ключевого слова impl
и затем используются после имени структуры, поскольку эти времена жизни являются частью типа структуры.
В сигнатурах методов внутри блока impl
ссылки могут быть привязаны ко времени жизни ссылок в полях структуры, либо могут быть независимыми. Вдобавок, правила неявного выведения времён жизни часто делают так, что аннотации переменных времён жизни являются необязательными в сигнатурах методов. Рассмотрим несколько примеров, использующих структуру с названием ImportantExcerpt
, которую мы определили в листинге 10-24.
Сначала, воспользуемся методом 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().unwrap(); 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().unwrap(); 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
появляются при попытках создания недействительных ссылок или несовпадения имеющихся времён жизни. В таких случаях, решение заключается в исправлении таких проблем, а не в указании статического времени жизни '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-21, которая возвращает наибольший из двух срезов строки. Но теперь у неё есть дополнительный параметр с именем 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, которые проверяют, что не тестовый код работает ожидаемым образом. Содержимое тестовых функций обычно выполняет следующие три действия:
- Установка любых необходимых данных или состояния.
- Запуск кода, который вы хотите проверить.
- Утверждение, что результаты являются теми, которые вы ожидаете.
Давайте рассмотрим функции предоставляемые в 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
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
Сейчас давайте проигнорируем первые две строчки кода и сосредоточимся на функции. Обратите внимание на синтаксис аннотации #[test]
: этот атрибут указывает, что это тестовая функция, поэтому запускающий тестирование знает, что эту функцию следует рассматривать как тестовую. У нас также могут быть не тестируемые функции в модуле tests
, которые помогут настроить общие сценарии или выполнить общие операции, поэтому нам всегда нужно указывать, какие функции являются тестами.
В теле функции теста используется макрос assert_eq!
, чтобы утверждать, что result
, который содержит результат сложения 2 и 2, равен 4. Это утверждение служит примером формата для типичного теста. Давайте запустим его, чтобы убедиться, что этот тест пройден.
Команда cargo test
выполнит все тесты в выбранном проекте и сообщит о результатах как в листинге 11-2:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.57s
Running unittests src/lib.rs (file:///projects/adder/target/debug/deps/adder-7acb243c25ffd9dc)
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
Cargo скомпилировал и выполнил тест. Мы видим строку running 1 test
. Следующая строка показывает имя сгенерированной тестовой функции, называемой it_works
, и результат запуска этого теста равный ok
. Текст test result: ok.
означает, что все тесты пройдены успешно и часть вывода 1 passed; 0 failed
сообщает общее количество тестов, которые прошли или были ошибочными.
Можно пометить тест как игнорируемый, чтобы он не выполнялся в конкретном случае; мы рассмотрим это в разделе “Игнорирование некоторых тестов, если их специально не запрашивать” позже в этой главе. Поскольку в данный момент мы этого не сделали, в сводке показано, что 0 ignored
. Мы также можем передать аргумент команде cargo test
для запуска только тех тестов, имя которых соответствует строке; это называется фильтрацией, и мы рассмотрим это в разделе