Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Введение в эксперимент

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

1. Викторины

Основная механика — викторины: на каждой странице есть несколько вопросов по её содержанию. У нас есть два правила относительно викторин в этом эксперименте:

  1. Проходите викторину сразу, как до неё доберётесь.
  2. Не пропускайте викторины.

(Мы не контролируем выполнение этих правил, но просим вас следовать им!)

Каждая викторина выглядит так, как показано ниже. Попробуйте, нажав «Start».

Если вы ответили на вопрос неправильно, вы можете либо повторить попытку, либо увидеть правильные ответы. Мы рекомендуем повторять викторину, пока не получите 100% — не стесняйтесь перечитать материал перед повторной попыткой. Обратите внимание, что как только вы увидите правильные ответы, повторную попытку пройти нельзя.

Если вы обнаружили проблему в викторине или в другой части книги, вы можете сообщить о ней в нашем репозитории на GitHub: https://github.com/cognitive-engineering-lab/rust-book

2. Выделение текста

Другая механика — выделение текста: вы можете выделить любой фрагмент текста и либо выделить его цветом, либо оставить комментарий. После выделения текста нажмите кнопку ✏️ и оставьте необязательный комментарий.

👉 Попробуйте выделить этот текст! 👈

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

Примечание: ваши выделения исчезнут, если мы изменим выделенный вами контент. Также ваши выделения хранятся в файле cookie. Если вы блокируете файлы cookie или меняете браузер, вы не увидите предыдущие выделения.

3. …и не только!

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

  • 26 сентября 2024 г.
    • Добавлена глава Криса Кричо об асинхронном Rust, а также новые вопросы для викторин.
  • 16 февраля 2023 г.
    • Новая глава о владении заменила предыдущую главу 4.
  • 18 января 2023 г.
    • Вопросы добавлены для оставшихся глав книги.
  • 15 декабря 2022 г.
    • В книге добавлены новые разделы под названием «Инвентаризация владения» со сложными вопросами, связанными с владением.
  • 7 ноября 2022 г.
    • При повторной попытке будут показаны только вопросы с неправильными ответами.
    • В большинстве вопросов с множественным выбором варианты будут случайным образом перемешаны.
    • Некоторые вопросы теперь будут запрашивать ваше обоснование.
    • Многие вопросы обновлены на основе ваших отзывов. Продолжайте в том же духе!

Заинтересованы в участии в других экспериментах по упрощению изучения Rust? Пожалуйста, зарегистрируйтесь здесь: https://forms.gle/U3jEUkb2fGXykp1DA

4. Публикации

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

5. Благодарности

Эта работа частично поддержана DARPA по соглашению № HR00112420354, частично поддержана NSF по гранту № CCF-2227863 и частично поддержана Amazon Web Services. Любые мнения, выводы и рекомендации, выраженные в этом материале, принадлежат авторам и не отражают взгляды наших спонсоров. Кэрол Николс и Rust Foundation помогли с публикацией эксперимента. TRPL — это результат усердной работы многих людей до начала нашего эксперимента.

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

авторы Стив Клабник, Кэрол Николс и Крис Кричо, при участии сообщества Rust

(а также с экспериментальными изменениями!)

Эта версия текста предполагает, что вы используете Rust 1.85.0 (выпущен 2025-02-17) или новее с edition = "2024" в файле Cargo.toml всех проектов, чтобы настроить их на использование идиом Rust 2024 издания. См. раздел «Установка» главы 1 для установки или обновления Rust.

Экспериментальная версия доступна только онлайн и на английском языке. Неэкспериментальная версия доступна оффлайн с установками Rust, сделанными через rustup; выполните rustup doc --book, чтобы открыть.

Также доступно несколько переводов [неэкспериментальной версии], сделанных сообществом. Неэкспериментальный текст доступен в формате бумажной и электронной книги от No Starch Press.

Предисловие

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

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

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

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

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

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

— Николас Матсакис и Аарон Турон

Введение

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

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

Для кого предназначен Rust

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

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

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

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

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

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

Студенты

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

Компании

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

Разработчики открытого исходного кода

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

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

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

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

Для кого предназначена эта книга

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

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

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

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

Глава 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 (Application Programming Interface). Глава 8 обсуждает некоторые общие структуры данных коллекций, которые предоставляет стандартная библиотека, такие как векторы, строки и хэш-карты. Глава 9 исследует философию и техники обработки ошибок в Rust.

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

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

В главе 16 мы рассмотрим различные модели конкурентного программирования и поговорим о том, как Rust помогает вам программировать в нескольких потоках без страха. В главе 17 мы строим на этом, исследуя синтаксис async и await в Rust, а также задачи, будущие значения (futures) и потоки (streams), и облегчённую модель конкурентности, которую они обеспечивают.

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

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

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

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

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

FerrisЗначение
Ferris with a question markЭтот код не компилируется!
Ferris throwing up their handsЭтот код вызывает панику!
Ferris with one claw up, shruggingЭтот код не даёт желаемого поведения.

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

Исходный код

Исходные файлы, из которых генерируется эта книга, можно найти на 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!

Вам также понадобится линкер — программа, которую 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. На одном из этапов установки вас попросят установить Visual Studio. Это предоставит линкер и нативные библиотеки, необходимые для компиляции программ. Если вам нужна дополнительная помощь на этом шаге, см. https://rust-lang.github.io/rustup/installation/windows-msvc.html

Остальная часть книги использует команды, которые работают как в 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. Практически любой текстовый редактор справится с задачей! Однако многие текстовые редакторы и интегрированные среды разработки (IDE) имеют встроенную поддержку Rust. Вы всегда можете найти достаточно актуальный список многих редакторов и IDE на странице инструментов на сайте Rust.

Работа в автономном режиме с этой книгой

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

$ cargo new get-dependencies
$ cd get-dependencies
$ cargo add rand@0.8.5 trpl@0.2.0

Это закэширует загрузки этих пакетов, так что вам не нужно будет загружать их позже. После выполнения этой команды вам не нужно сохранять папку get-dependencies. Если вы выполнили эту команду, вы можете использовать флаг --offline со всеми командами cargo в остальной части книги, чтобы использовать эти кэшированные версии вместо попыток использовать сеть.

Привет, мир!

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

Примечание: Эта книга предполагает базовое знакомство с командной строкой. Rust не предъявляет особых требований к вашему редактору, инструментам или расположению кода, поэтому, если вы предпочитаете использовать интегрированную среду разработки (IDE) вместо командной строки, смело используйте свою любимую IDE. Во многих IDE сейчас есть определённая поддержка Rust; подробности смотрите в документации к IDE. Команда Rust сосредотачивается на обеспечении отличной поддержки IDE через rust-analyzer. Подробнее см. в Приложении D.

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

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

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

Для 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 и введите код из Листинга 1-1.

Filename: main.rs
fn main() {
    println!("Hello, world!");
}
Listing 1-1: Программа, выводящая Hello, world!

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

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

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

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

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

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

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

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

fn main() {

}

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

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

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

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

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

Эта строка выполняет всю работу в этой небольшой программе: она выводит текст на экран. Здесь есть три важных детали, на которые стоит обратить внимание.

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

Во-вторых, вы видите строку "Hello, world!". Мы передаём эту строку в качестве аргумента в 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 %= опция /B говорит показывать только имена файлов =%
main.exe
main.pdb
main.rs

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

$ ./main # или .\main в Windows

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

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

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

Привет, Cargo!

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

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

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

$ cargo --version

Если вы видите номер версии — отлично! Если вы видите ошибку, например 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 внутри.

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

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

Откройте Cargo.toml в текстовом редакторе по вашему выбору. Он должен выглядеть похоже на код в Листинге 1-2.

Filename: Cargo.toml
[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2024"

[dependencies]
Listing 1-2: Содержимое Cargo.toml, сгенерированное командой cargo new

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

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

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

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

Теперь откройте src/main.rs и посмотрите на него:

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

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

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

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

Если вы начали проект, который не использует Cargo, как мы сделали с проектом «Hello, world!», вы можете преобразовать его в проект, который использует Cargo. Переместите код проекта в каталог src и создайте соответствующий файл Cargo.toml. Один из простых способов получить этот файл Cargo.toml — запустить cargo init, который создаст его автоматически.

Сборка и запуск проекта 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 # или .\target\debug\hello_cargo.exe в Windows
Hello, world!

Если всё пройдёт хорошо, Hello, world! должно вывестись в терминал. При первом запуске cargo build Cargo также создаёт новый файл на верхнем уровне: 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 ускорит процесс, позволяя вам знать, компилируется ли ваш проект! Поэтому многие разработчики на Rust периодически запускают cargo check во время написания программы, чтобы убедиться, что она компилируется. Затем они запускают cargo build, когда готовы использовать исполняемый файл.

Давайте подытожим, что мы узнали до сих пор о Cargo:

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

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

Сборка для релиза

Когда ваш проект наконец готов к выпуску, вы можете использовать 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 = "2024"

[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.08s
     Running `target/debug/guessing_game`
Hello, world!

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

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

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

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

Filename: 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}");
}
Listing 2-1: Код, который получает догадку от пользователя и выводит её

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

use std::io;

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

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

    let mut guess = String::new();

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

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

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

Если тип, который вы хотите использовать, отсутствует в прелюдии, вы должны явно подключить этот тип в область видимости с помощью оператора 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 указывает, что операция прошла успешно, и содержит успешно сгенерированное значение. Вариант Err означает, что операция завершилась сбоем, и содержит информацию о том, как или почему операция завершилась сбоем.

У значений типа Result, как и у значений любого типа, есть определённые для них методы. У экземпляра Result есть метод expect, который вы можете вызвать. Если этот экземпляр Result является значением Err, expect приведёт к аварийному завершению программы и отобразит сообщение, которое вы передали в качестве аргумента в 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` profile [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 крейт с такой функциональностью.

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

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

Координация внешних крейтов Cargo — это то, где 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 считает, что эти версии имеют совместимые с версией 0.8.5 публичные API, и это указание гарантирует, что вы получите последний патч-релиз, который всё ещё будет компилироваться с кодом в этой главе. Любая версия 0.9.0 или выше не гарантирует того же API, что и следующие примеры.

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

$ cargo build
  Updating crates.io index
   Locking 15 packages to latest Rust 1.85.0 compatible versions
    Adding rand v0.8.5 (available: v0.9.0)
 Compiling proc-macro2 v1.0.93
 Compiling unicode-ident v1.0.17
 Compiling libc v0.2.170
 Compiling cfg-if v1.0.0
 Compiling byteorder v1.5.0
 Compiling getrandom v0.2.15
 Compiling rand_core v0.6.4
 Compiling quote v1.0.38
 Compiling syn v2.0.98
 Compiling zerocopy-derive v0.7.35
 Compiling zerocopy v0.7.35
 Compiling ppv-lite86 v0.2.20
 Compiling rand_chacha v0.3.1
 Compiling rand v0.8.5
 Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
  Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.48s
Listing 2-2: Вывод при запуске cargo build после добавления крейта rand в качестве зависимости

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

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

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

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

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

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

Эти строки показывают, что 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
     Locking 1 package to latest Rust 1.85.0 compatible version
    Updating rand v0.8.5 -> v0.8.6 (available: v0.9.0)

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 очень упрощает повторное использование библиотек, поэтому Rustaceans могут писать меньшие проекты, собранные из ряда пакетов.

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

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

Filename: 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}");
}
Listing 2-3: Добавление кода для генерации случайного числа

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

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

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

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

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

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     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` profile [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. Обратите внимание, что этот код пока не скомпилируется, так как мы объясним почему.

Filename: src/main.rs
use std::cmp::Ordering;
use std::io;

use rand::Rng;

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!"),
    }
}
Listing 2-4: Обработка возможных возвращаемых значений при сравнении двух чисел

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

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

Выражение match состоит из ветвей. Ветвь состоит из шаблона для сопоставления и кода, который должен выполняться, если значение, переданное в match, соответствует шаблону этой ветви. Rust берёт значение, переданное в match, и последовательно проверяет шаблоны каждой ветви. Шаблоны и конструкция match — это мощные возможности Rust: они позволяют вам выразить множество ситуаций, с которыми может столкнуться ваш код, и гарантируют, что вы обработаете их все. Эти возможности будут подробно рассмотрены в главе 6 и главе 19 соответственно.

Давайте разберём пример с выражением 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:23:21
   |
23 |     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
  --> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/cmp.rs:964:8

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 std::cmp::Ordering;
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.");

    // --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 возвращает вариант Err Result, потому что не смог создать число из строки, вызов expect аварийно завершит игру и выведет сообщение, которое мы ему даём. Если parse может успешно преобразовать строку в число, он вернёт вариант Ok Result, и expect вернёт число, которое мы хотим из значения Ok.

Давайте запустим программу сейчас:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.26s
     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 std::cmp::Ordering;
use std::io;

use rand::Rng;

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` profile [unoptimized + debuginfo] target(s) in 0.23s
     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 src/main.rs:28:47:
Please type a number!: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

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

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

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

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

use std::cmp::Ordering;
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}");

    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.

Filename: src/main.rs
use std::cmp::Ordering;
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}");

    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;
            }
        }
    }
}
Listing 2-5: Игнорирование догадки не-числа и запрос другой догадки вместо аварийного завершения программы

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

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

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

Теперь всё в программе должно работать так, как ожидается. Давайте попробуем:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
     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 показывает окончательный код.

Filename: src/main.rs
use std::cmp::Ordering;
use std::io;

use rand::Rng;

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;
            }
        }
    }
}
Listing 2-6: Полный код игры «Угадай число»

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

Резюме

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

Основные концепции программирования

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

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

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

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

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

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

Когда переменная является неизменяемой, после связывания значения с именем вы не можете изменить это значение. Чтобы это проиллюстрировать, создайте новый проект с именем variables в вашем каталоге projects с помощью cargo new variables.

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

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

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

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

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

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

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

Вы получили сообщение об ошибке cannot assign twice to immutable variable `x` (нельзя дважды присваивать значение неизменяемой переменной 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 о вычислении констант для получения дополнительной информации о том, какие операции можно использовать при объявлении констант.

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

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

Затенение

Как вы видели в учебнике по игре угадывания в Главе 2, вы можете объявить новую переменную с тем же именем, что и у предыдущей переменной. Разработчики на Rust говорят, что первая переменная затенена второй, что означает, что вторая переменная — та, которую увидит компилятор, когда вы используете имя переменной. По сути, вторая переменная затмевает первую, принимая на себя все использования имени переменной, пока либо она сама не будет затенена, либо не закончится область видимости. Мы можем затенять переменную, используя то же имя переменной и повторяя использование ключевого слова 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 — статически типизированный язык, что означает, что он должен знать типы всех переменных во время компиляции. Компилятор обычно может вывести, какой тип мы хотим использовать, на основе значения и того, как мы его используем. В случаях, когда возможны многие типы, например, когда мы преобразовали 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.

Целые числа

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

Таблица 3-1: Типы целых чисел в Rust

ДлинаЗнаковыйБеззнаковый
8-битныйi8u8
16-битныйi16u16
32-битныйi32u32
64-битныйi64u64
128-битныйi128u128
Зависимый от архитектурыisizeusize

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

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

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

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

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

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

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

Переполнение целых чисел

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

При компиляции в режиме выпуска с флагом --release Rust не включает проверки на переполнение целых чисел, вызывающие панику. Вместо этого, если происходит переполнение, Rust выполняет дополнительное обёртывание. Короче говоря, значения, превышающие максимальное значение, которое может хранить тип, «заворачиваются» до минимального значения типа. В случае с 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.

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

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. Мы обсудим эту тему подробно в «Хранение текста в кодировке UTF-8 со строками» в главе 8.

Составные типы

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

Тип кортежа

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

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

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

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

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

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

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

    let (x, y, z) = tup;

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

Эта программа сначала создаёт кортеж и связывает его с переменной tup. Затем она использует образец с let, чтобы взять tup и превратить его в три отдельные переменные x, y и z. Это называется деструктуризацией, потому что она разбивает один кортеж на три части. Наконец, программа выводит значение 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.

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

Кроме того, мы можем изменять отдельные элементы изменяемого кортежа. Например:

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

fn main() {
    let mut x: (i32, i32) = (1, 2);
    x.0 = 0;
    x.1 += 5;
}

Эта программа устанавливает первый элемент в ноль и добавляет пять ко второму элементу. Конечное значение x равно (0, 7).

Тип массива

Другой способ иметь коллекцию из нескольких значений — это массив. В отличие от кортежа, каждый элемент массива должен иметь один и тот же тип. В отличие от массивов в некоторых других языках, массивы в 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 указывает, что массив содержит пять элементов.

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

#![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 src/main.rs:19:19:
index out of bounds: the len is 5 but the index is 10
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 для функций и переменных, при котором все буквы строчные, а слова разделяются символами подчеркивания. Вот программа, содержащая пример определения функции:

Filename: 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 мы добавляем параметр:

Filename: 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: требование аннотаций типов в определениях функций означает, что компилятор почти никогда не потребует от вас использовать их в других местах кода, чтобы понять, какой тип вы имеете в виду. Компилятор также способен давать более полезные сообщения об ошибках, если он знает, какие типы ожидает функция.

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

Filename: 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.

Давайте попробуем запустить этот код. Замените программу, которая сейчас находится в файле src/main.rs вашего проекта functions, приведенным выше примером и запустите его с помощью 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; — это инструкция.

Filename: src/main.rs
fn main() {
    let y = 6;
}
Listing 3-1: Объявление функции main, содержащее одну инструкцию

Определения функций также являются инструкциями; весь предыдущий пример сам по себе является инструкцией. (Как мы увидим ниже, вызов функции не является инструкцией, однако.)

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

Filename: 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. Вызов функции — это выражение. Вызов макроса — это выражение. Новый блок области видимости, созданный с помощью фигурных скобок, — это выражение, например:

Filename: 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 и указав значение, но большинство функций неявно возвращают последнее выражение. Вот пример функции, которая возвращает значение:

Filename: 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 без точки с запятой, потому что это выражение, значение которого мы хотим вернуть.

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

Filename: 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, превратив её из выражения в инструкцию, мы получим ошибку:

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

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

Комментарии

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

Вот простой комментарий:

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

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

#![allow(unused)]
fn main() {
// Здесь мы делаем что-то сложное, настолько, что требуется
// несколько строк комментариев! Фух! Надеюсь, этот комментарий
// объясняет, что происходит.
}

Или можно использовать синтаксис многострочных комментариев с /* и */:

#![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 также есть другой вид комментариев — комментарии документации, которые мы обсудим в разделе 14 «Публикация крейта на Crates.io» publishing.

Управление потоком выполнения

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

Выражения if

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

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

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

fn main() {
    let number = 3;

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

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

Опционально мы также можем включить выражение else, что мы и сделали здесь, чтобы дать программе альтернативный блок кода для выполнения, если условие оценивается как false. Если вы не предоставите выражение else и условие будет false, программа просто пропустит блок 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 выполняет только блок для первого true условия, и как только находит одно, он даже не проверяет остальные.

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

Использование if в инструкции let

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

Filename: src/main.rs
fn main() {
    let condition = true;
    let number = if condition { 5 } else { 6 };

    println!("The value of number is: {number}");
}
Listing 3-2: Присвоение результата выражения if переменной

Переменная 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 должен знать на этапе компиляции, каким типом definitively является переменная 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` profile [unoptimized + debuginfo] target(s) in 0.08s
     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, а затем проверяем, равен ли counter 10. Когда это так, мы используем ключевое слово break со значением counter * 2. После цикла мы используем точку с запятой, чтобы завершить оператор, который присваивает значение result. Наконец, мы печатаем значение в result, которое в данном случае равно 20.

Вы также можете использовать return внутри цикла. Хотя break выходит только из текущего цикла, return всегда выходит из текущей функции.

Примечание: точка с запятой после break counter * 2 технически необязательна. break очень похож на return в том смысле, что оба могут необязательно принимать выражение в качестве аргумента, оба вызывают изменение потока управления. Код после break или return никогда не выполняется, поэтому компилятор Rust рассматривает выражение break и выражение return как имеющие значение единицы, или ().

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

Если у вас есть циклы внутри циклов, break и continue применяются к самому внутреннему циклу в этой точке. Вы можете опционально указать метку цикла (loop label) на цикле, которую затем можно использовать с 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

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

Filename: src/main.rs
fn main() {
    let mut number = 3;

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

        number -= 1;
    }

    println!("LIFTOFF!!!");
}
Listing 3-3: Использование цикла while для выполнения кода, пока условие оценивается как true

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

Цикл по коллекции с помощью for

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

Filename: 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;
    }
}
Listing 3-4: Перебор каждого элемента коллекции с использованием цикла while

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

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

Filename: src/main.rs
fn main() {
    let a = [10, 20, 30, 40, 50];

    for element in a {
        println!("the value is: {element}");
    }
}
Listing 3-5: Перебор каждого элемента коллекции с использованием цикла for

Когда мы запускаем этот код, мы увидим тот же вывод, что и в Листинге 3-4. Что более важно, мы теперь повысили безопасность кода и устранили шанс ошибок, которые могли бы возникнуть из-за выхода за конец массива или недостаточного прохода и пропуска некоторых элементов. Машинный код, сгенерированный из циклов for, также может быть более эффективным, потому что индекс не нужно сравнивать с длиной массива на каждой итерации.

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

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

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

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

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

Этот код немного приятнее, не так ли?

Резюме

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

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

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

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

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

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

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

Безопасность — это отсутствие неопределённого поведения

Начнём с примера. Эта программа безопасна для выполнения:

fn read(y: bool) {
    if y {
        println!("y is true!");
    }
}

fn main() {
    let x = true;
    read(x);
}

Мы можем сделать эту программу небезопасной, переместив вызов read перед определением x:

fn read(y: bool) {
    if y {
        println!("y is true!");
    }
}

fn main() {
    read(x); // oh no! x isn't defined!
    let x = true;
}

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

Вторая программа небезопасна, потому что read(x) ожидает, что x имеет значение типа bool, но x ещё не имеет значения.

Когда такая программа выполняется интерпретатором, чтение x до её определения вызывает исключение, например NameError в Python или ReferenceError в JavaScript. Но исключения имеют стоимость. Каждый раз, когда интерпретируемая программа читает переменную, интерпретатор должен проверить, определена ли эта переменная.

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

error[E0425]: cannot find value `x` in this scope
 --> src/main.rs:8:10
  |
8 |     read(x); // oh no! x isn't defined!
  |          ^ not found in this scope

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

Сначала рассмотрим, как компилируется и выполняется безопасная программа. На компьютере с процессором архитектуры x86 Rust генерирует следующий ассемблерный код для функции main в безопасной программе (см. полный ассемблерный код здесь):

main:
    ; ...
    mov     edi, 1
    call    read
    ; ...

Примечание: если вы не знакомы с ассемблерным кодом, это нормально! Этот раздел содержит несколько примеров ассемблера, чтобы показать, как Rust работает на самом деле. Для понимания Rust обычно не нужно знать ассемблер.

Этот ассемблерный код:

  • Перемещает число 1, представляющее true, в регистр (вид переменной в ассемблере) под названием edi.
  • Вызывает функцию read, которая ожидает, что её первый аргумент y будет в регистре edi.

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

main:
    ; ...
    call    read
    mov     edi, 1    ; mov после call
    ; ...

Эта программа небезопасна, потому что read ожидает, что edi будет логическим значением, то есть числом 0 или 1. Но edi может быть чем угодно: 2, 100, 0x1337BEEF. Когда read попытается использовать свой аргумент y для любой цели, это немедленно вызовет НЕОПРЕДЕЛЁННОЕ ПОВЕДЕНИЕ!

Rust не определяет, что происходит, если попытаться выполнить if y { .. }, когда y не равно true или false. Это поведение, или то, что происходит после выполнения инструкции, является неопределённым. Может произойти что-то, например:

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

Фундаментальная цель Rust — гарантировать, что ваши программы никогда не имеют неопределённого поведения. Вот что значит “безопасность”. Неопределённое поведение особенно опасно для низкоуровневых программ с прямым доступом к памяти. Около 70% сообщённых уязвимостей безопасности в низкоуровневых системах вызвано повреждением памяти, что является одной из форм неопределённого поведения.

Вторичная цель Rust — предотвращать неопределённое поведение на этапе компиляции, а не выполнения. У этой цели две мотивации:

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

Rust не может предотвратить все ошибки. Если приложение предоставляет публичный и неаутентифицированный endpoint /delete-production-database, то злоумышленнику не нужна подозрительная инструкция if для удаления базы данных. Но защиты Rust всё ещё могут сделать программы безопаснее по сравнению с использованием языка с меньшим количеством защит, как показала, например, команда Android от Google.

Владение как дисциплина для безопасности памяти

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

Память — это пространство, где хранятся данные во время выполнения программы. Есть много способов думать о памяти:

  • Если вы не знакомы с системным программированием, вы можете думать о памяти на высоком уровне, например “память — это ОЗУ в моём компьютере” или “память — это то, что заканчивается, если я загружаю слишком много данных”.
  • Если вы знакомы с системным программированием, вы можете думать о памяти на низком уровне, например “память — это массив байтов” или “память — это указатели, которые я получаю от malloc”.

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

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

Переменные живут в стеке

Вот программа, похожая на ту, что вы видели в разделе 3.3, которая определяет число n и вызывает функцию plus_one для n. Под программой — новый вид диаграммы. Эта диаграмма визуализирует содержимое памяти во время выполнения программы в трёх отмеченных точках.

Переменные живут в кадрах. Кадр — это сопоставление переменных со значениями в пределах одной области видимости, такой как функция. Например:

  • Кадр для main в точке L1 содержит n = 5.
  • Кадр для plus_one в L2 содержит x = 5.
  • Кадр для main в точке L3 содержит n = 5; y = 6.

Кадры организованы в стек текущих вызываемых функций. Например, в L2 кадр для main находится над кадром для вызываемой функции plus_one. После того, как функция возвращается, Rust освобождает кадр функции. (Освобождение также называется освобождением или удалением, и мы используем эти термины взаимозаменяемо.) Эта последовательность кадров называется стеком, потому что самый недавний добавленный кадр всегда является следующим освобождаемым кадром.

Примечание: эта модель памяти не полностью описывает, как Rust работает на самом деле! Как мы видели ранее с ассемблерным кодом, компилятор Rust может поместить n или x в регистр, а не в кадр стека. Но это различие — деталь реализации. Это не должно менять ваше понимание безопасности в Rust, поэтому мы можем сосредоточиться на более простом случае переменных только в кадрах.

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

Значение a копируется в b, и a остаётся неизменным, даже после изменения b.

Box’ы живут в куче

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

Заметьте, что копирование a в b приводит к тому, что кадр main содержит 2 миллиона элементов.

Чтобы передать доступ к данным без их копирования, Rust использует указатели. Указатель — это значение, описывающее расположение в памяти. Значение, на которое указывает указатель, называется объектом указателя. Один из распространённых способов создать указатель — выделить память в куче. Куча — это отдельная область памяти, где данные могут существовать неограниченно долго. Данные в куче не привязаны к конкретному кадру стека. Rust предоставляет конструкцию под названием Box для размещения данных в куче. Например, мы можем обернуть массив из миллиона элементов в Box::new так:

Заметьте, что теперь существует только один массив за раз. В L1 значение a — это указатель (представлен точкой со стрелкой) на массив внутри кучи. Инструкция let b = a копирует указатель из a в b, но данные, на которые указывает указатель, не копируются. Обратите внимание, что a теперь серое, потому что оно было перемещено — мы увидим, что это значит, через мгновение.

Rust не разрешает ручное управление памятью

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

Как мы видели выше, данные в куче выделяются при вызове Box::new(..). Но когда данные в куче освобождаются? Представьте, что у Rust есть функция free(), которая освобождает выделение в куче. Представьте, что Rust позволяет программисту вызывать free когда угодно. Такой “ручной” способ управления памятью легко приводит к ошибкам. Например, мы могли бы прочитать указатель на освобождённую память:

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

Здесь мы выделяем массив в куче. Затем вызываем free(b), которая освобождает память кучи для b. Поэтому значение b — это указатель на недействительную память, которую мы представляем иконкой “⦻”. Неопределённое поведение ещё не произошло! Программа всё ещё безопасна в L2. Не обязательно проблема иметь недействительный указатель.

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

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

Владелец Box’а управляет освобождением

Вместо этого Rust автоматически освобождает память кучи для box’а. Вот почти правильное описание политики Rust для освобождения box’ов:

Принцип освобождения Box’а (почти правильно): Если переменная связана с box’ом, когда Rust освобождает кадр переменной, тогда Rust освобождает память кучи box’а.

Например, давайте проследим программу, которая выделяет и освобождает box:

В L1, перед вызовом make_and_drop, состояние памяти — это просто кадр стека для main. Затем в L2, во время вызова make_and_drop, a_box указывает на 5 в куче. Как только make_and_drop завершается, Rust освобождает его кадр стека. make_and_drop содержит переменную a_box, поэтому Rust также освобождает данные в куче в a_box. Поэтому куча пуста в L3.

Память кучи для box’а была успешно управлена. Но что, если мы злоупотребим этой системой? Возвращаясь к нашему предыдущему примеру, что происходит, когда мы связываем две переменные с box’ом?

fn main() {
let a = Box::new([0; 1_000_000]);
let b = a;
}

Массивированный массив теперь связан как с a, так и с b. Согласно нашему “почти правильному” принципу, Rust попытается освободить память кучи box’а дважды от имени обеих переменных. Это тоже неопределённое поведение!

Чтобы избежать этой ситуации, мы наконец приходим к владению. Когда a связывается с Box::new([0; 1_000_000]), мы говорим, что a владеет box’ом. Инструкция let b = a перемещает владение box’ом из a в b. Учитывая эти концепции, политика Rust для освобождения box’ов более точно описывается так:

Принцип освобождения Box’а (полностью правильно): Если переменная владеет box’ом, когда Rust освобождает кадр переменной, тогда Rust освобождает память кучи box’ом.

В примере выше b владеет box’ом с массивом. Поэтому когда область видимости заканчивается, Rust освобождает box только один раз от имени b, а не a.

Коллекции используют Box’ы

Box’ы используются структурами данных Rust1 такими как Vec, String и HashMap для хранения переменного числа элементов. Например, вот программа, которая создаёт, перемещает и изменяет строку:

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

  1. В L1 строка “Ferris” была выделена в куче. Её владеет first.
  2. В L2 была вызвана функция add_suffix(first). Это перемещает владение строкой из first в name. Данные строки не копируются, но указатель на данные копируется.
  3. В L3 функция name.push_str(" Jr.") изменяет размер выделения кучи для строки. Это делает три вещи. Во-первых, создаётся новое большее выделение. Во-вторых, в новое выделение записывается “Ferris Jr.”. В-третьих, освобождается исходная память кучи. first теперь указывает на освобождённую память.
  4. В L4 кадр для add_suffix исчез. Эта функция вернула name, передавая владение строкой full.

Переменные нельзя использовать после перемещения

Программа со строкой помогает проиллюстрировать ключевой принцип безопасности для владения. Представьте, что first используется в main после вызова add_suffix. Мы можем смоделировать такую программу и увидеть неопределённое поведение, которое возникает:

first указывает на освобождённую память после вызова add_suffix. Поэтому чтение first в println! было бы нарушением безопасности памяти (неопределённое поведение). Помните: проблема не в том, что first указывает на освобождённую память. Проблема в том, что мы попытались использовать first после того, как она стала недействительной.

К счастью, Rust откажется компилировать эту программу, выдавая следующую ошибку:

error[E0382]: borrow of moved value: `first`
 --> test.rs:4:35
  |
2 |     let first = String::from("Ferris");
  |         ----- move occurs because `first` has type `String`, which does not implement the `Copy` trait
3 |     let full = add_suffix(first);
  |                           ----- value moved here
4 |     println!("{full}, originally {first}"); // first is now used here
  |                                   ^^^^^ value borrowed here after move

Давайте пройдёмся по шагам этой ошибки. Rust говорит, что first перемещена при вызове add_suffix(first) в строке 3. Ошибка уточняет, что first перемещена, потому что имеет тип String, который не реализует Copy. Мы обсудим Copy скоро — вкратце, вы не получили бы эту ошибку, если бы использовали i32 вместо String. Наконец, ошибка говорит, что мы используем first после перемещения (она “заимствована”, что мы обсуждаем в следующем разделе).

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

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

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

Клонирование избегает перемещений

Один из способов избежать перемещения данных — клонировать их, используя метод .clone(). Например, мы можем исправить проблему безопасности в предыдущей программе с клоном:

Заметьте, что в L1 first_clone не “поверхностно” скопировал указатель в first, а вместо этого “глубоко” скопировал данные строки в новое выделение кучи. Поэтому в L2, пока first_clone была перемещена и сделана недействительной add_suffix, исходная переменная first остаётся неизменной. Безопасно продолжать использовать first.

Резюме

Владение в первую очередь является дисциплиной управления кучей:2

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

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


  1. Эти структуры данных не используют буквальный тип Box. Например, String реализован с Vec, а Vec реализован с RawVec, а не с Box. Но типы вроде RawVec всё ещё похожи на box: они владеют памятью в куче.

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

Ссылки и заимствование

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

В этом примере вызов greet перемещает данные из m1 и m2 в параметры greet. Обе строки удаляются в конце greet, и поэтому не могут быть использованы в main. Если попытаться прочитать их, как в операции format!(..), это приведёт к неопределённому поведению. Компилятор Rust поэтому отклоняет эту программу с той же ошибкой, что и в предыдущем разделе:

error[E0382]: borrow of moved value: `m1`
 --> test.rs:5:30
 (...rest of the error...)

Такое поведение с перемещением крайне неудобно. Программы часто должны использовать строку более одного раза. Альтернативный greet мог бы возвращать владение строками, например так:

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

Ссылки — это указатели, не владеющие данными

Ссылка — это вид указателя. Вот пример ссылки, который переписывает нашу программу greet более удобным способом:

Выражение &m1 использует оператор амперсанда для создания ссылки на (или “заимствования”) m1. Тип параметра g1 в greet изменён на &String, что означает “ссылка на String”.

Обратите внимание, что на L2 есть два шага от g1 до строки “Hello”. g1 — это ссылка, указывающая на m1 в стеке, а m1 — это String, содержащая коробку, которая указывает на “Hello” в куче.

Хотя m1 владеет данными в куче “Hello”, g1 не владеет ни m1, ни “Hello”. Поэтому после завершения greet и достижения программы L3 никакие данные в куче не освобождаются. Исчезает только стековый фрейм для greet. Этот факт согласуется с нашим Принципом освобождения коробок. Поскольку g1 не владел “Hello”, Rust не освободил “Hello” от имени g1.

Ссылки — это невладеющие указатели, потому что они не владеют данными, на которые указывают.

Разыменование указателя даёт доступ к его данным

Предыдущие примеры с коробками и строками не показывали, как Rust “следует” за указателем к его данным. Например, макрос println! таинственным образом работал как для владеющих строк типа String, так и для ссылок на строки типа &String. Основной механизм — это оператор разыменования, обозначаемый звёздочкой (*). Например, вот программа, использующая разыменования несколькими способами:

Обратите внимание на разницу между r1, указывающим на x в стеке, и r2, указывающим на значение в куче 2.

Вы, вероятно, не увидите оператор разыменования очень часто при чтении кода Rust. Rust неявно вставляет разыменования и ссылки в определённых случаях, например при вызове метода с точечным оператором. Например, эта программа показывает два эквивалентных способа вызова функций i32::abs (абсолютное значение) и str::len (длина строки):

fn main()  {
let x: Box<i32> = Box::new(-1);
let x_abs1 = i32::abs(*x); // явное разыменование
let x_abs2 = x.abs();      // неявное разыменование
assert_eq!(x_abs1, x_abs2);

let r: &Box<i32> = &x;
let r_abs1 = i32::abs(**r); // явное разыменование (дважды)
let r_abs2 = r.abs();       // неявное разыменование (дважды)
assert_eq!(r_abs1, r_abs2);

let s = String::from("Hello");
let s_len1 = str::len(&s); // явная ссылка
let s_len2 = s.len();      // неявная ссылка
assert_eq!(s_len1, s_len2);
}

Этот пример показывает неявные преобразования тремя способами:

  1. Функция i32::abs ожидает на вход тип i32. Чтобы вызвать abs с Box<i32>, можно явно разыменовать коробку, как i32::abs(*x). Можно также неявно разыменовать коробку, используя синтаксис вызова метода, как x.abs(). Точечный синтаксис — это синтаксический сахар для синтаксиса вызова функции.

  2. Это неявное преобразование работает для нескольких слоёв указателей. Например, вызов abs на ссылке на коробку r: &Box<i32> вставит два разыменования.

  3. Это преобразование также работает в обратном направлении. Функция str::len ожидает ссылку &str. Если вызвать len на владеющей String, то Rust вставит один оператор заимствования. (На самом деле, есть дальнейшее преобразование из String в str!)

Мы подробнее расскажем о вызовах методов и неявных преобразованиях в следующих главах. Пока важно понять, что эти преобразования происходят при вызовах методов и некоторых макросах, таких как println. Мы хотим распутать всю “магию” Rust, чтобы у вас было чёткое понимание того, как работает Rust.

Rust избегает одновременного алиасинга и мутации

Указатели — мощная и опасная особенность, потому что они позволяют алиасинг. Алиасинг — это доступ к одним и тем же данным через разные переменные. Сам по себе алиасинг безвреден. Но в сочетании с мутацией это рецепт для катастрофы. Одна переменная может “выдернуть ковёр” из-под другой переменной многими способами, например:

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

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

Макрос vec! создаёт вектор с элементами между скобками. Вектор v имеет тип Vec<i32>. Синтаксис <i32> означает, что элементы вектора имеют тип i32.

Одна важная деталь реализации: v выделяет массив в куче определённой ёмкости. Мы можем заглянуть во внутренности Vec и увидеть эту деталь:

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

Обратите внимание, что вектор имеет длину (len) 3 и ёмкость (cap) 3. Вектор заполнен до ёмкости. Поэтому при push вектор должен создать новое выделение с большей ёмкостью, скопировать все элементы и освободить исходный массив в куче. На диаграмме выше массив 1 2 3 4 находится в (потенциально) другом месте памяти, чем исходный массив 1 2 3.

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

Изначально v указывает на массив с 3 элементами в куче. Затем num создаётся как ссылка на третий элемент, как видно на L1. Однако операция v.push(4) изменяет размер v. Изменение размера освободит предыдущий массив и выделит новый, больший массив. В процессе num остаётся указывать на недействительную память. Поэтому на L3 разыменование *num читает недействительную память, вызывая неопределённое поведение.

В более абстрактных терминах проблема в том, что вектор v одновременно алиасирован (ссылкой num) и мутируется (операцией v.push(4)). Поэтому чтобы избежать подобных проблем, Rust следует базовому принципу:

Принцип безопасности указателей: данные никогда не должны быть алиасированы и мутированы одновременно.

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

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

Ссылки изменяют разрешения на места

Основная идея проверки заимствований в том, что переменные имеют три вида разрешений на свои данные:

  • Чтение (R): данные могут быть скопированы в другое место.
  • Запись (W): данные могут быть изменены.
  • Владение (O): данные могут быть перемещены или удалены.

Эти разрешения не существуют во время выполнения, только внутри компилятора. Они описывают, как компилятор “думает” о вашей программе до её выполнения.

По умолчанию переменная имеет разрешения чтение/владение (RO) на свои данные. Если переменная аннотирована let mut, то она также имеет разрешение запись (W). Ключевая идея в том, что ссылки могут временно снимать эти разрешения.

Чтобы проиллюстрировать эту идею, давайте посмотрим на разрешения в вариации программы выше, которая на самом деле безопасна. push был перемещён после println!. Разрешения в этой программе визуализированы новым видом диаграммы. Диаграмма показывает изменения разрешений на каждой строке.

Давайте пройдёмся по каждой строке:

  1. После let mut v = (...), переменная v была инициализирована (обозначено ). Она получает разрешения +R+W+O (знак плюс указывает на получение).
  2. После let num = &v[2], данные в v были заимствованы num (обозначено ). Происходят три вещи:
    • Заимствование снимает разрешения
      W
      O
      с v (косая черта указывает на потерю). v не может быть записан или владеем, но всё ещё может быть прочитан.
    • Переменная num получила разрешения RO. num не является изменяемым (отсутствующее разрешение W показано как тире ), потому что оно не было отмечено let mut.
    • Место *num получило разрешение R.
  3. После println!(...), num больше не используется, поэтому v больше не заимствован. Следовательно:
    • v восстанавливает свои разрешения WO (обозначено ).
    • num и *num потеряли все свои разрешения (обозначено ).
  4. После v.push(4), v больше не используется, и он теряет все свои разрешения.

Далее рассмотрим несколько нюансов диаграммы. Во-первых, почему вы видите и num, и *num? Потому что доступ к данным через ссылку — это не то же самое, что манипулирование самой ссылкой. Например, допустим, мы объявили ссылку на число с let mut:

Обратите внимание, что x_ref имеет разрешение W, в то время как *x_ref — нет. Это означает, что мы можем присвоить другую ссылку переменной x_ref (например, x_ref = &y), но не можем изменить данные, на которые она указывает (например, *x_ref += 1).

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

  • Переменные, такие как a.
  • Разыменования мест, такие как *a.
  • Доступы к массиву мест, такие как a[0].
  • Поля мест, такие как a.0 для кортежей или a.field для структур (обсуждается в следующей главе).
  • Любые комбинации вышеперечисленного, такие как *((*a)[0].1).

Во-вторых, почему места теряют разрешения, когда они становятся неиспользуемыми? Потому что некоторые разрешения взаимно исключают друг друга. Если вы пишете num = &v[2], то v не может быть изменён или удалён, пока num используется. Но это не значит, что использовать num снова недействительно. Например, если мы добавим ещё один println! в вышеприведённую программу, то num просто потеряет свои разрешения на строку позже:

Проблема возникает только при попытке использовать num снова после изменения v. Давайте рассмотрим это подробнее.

Проверка заимствований находит нарушения разрешений

Вспомним Принцип безопасности указателей: данные не должны быть алиасированы и мутированы. Цель этих разрешений — гарантировать, что данные не могут быть мутированы, если они алиасированы. Создание ссылки на данные (“заимствование”) приводит к тому, что эти данные временно становятся доступны только для чтения, пока ссылка не перестаёт использоваться.

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

Каждый раз, когда место используется, Rust ожидает, что у места будут определённые разрешения в зависимости от операции. Например, заимствование &v[2] требует, чтобы v было читаемым. Поэтому разрешение R показано между операцией & и местом v. Буква заполнена, потому что v имеет разрешение чтения на этой строке.

Напротив, мутирующая операция v.push(4) требует, чтобы v было читаемым и изменяемым. Оба разрешения R и W показаны. Однако v не имеет разрешения записи (оно заимствовано num). Поэтому буква W полая, указывая, что разрешение записи ожидается, но v его не имеет.

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

error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> test.rs:4:1
  |
3 | let num: &i32 = &v[2];
  |                  - immutable borrow occurs here
4 | v.push(4);
  | ^^^^^^^^^ mutable borrow occurs here
5 | println!("Third element is {}", *num);
  |                                 ---- immutable borrow later used here

Сообщение об ошибке объясняет, что v не может быть изменено, пока ссылка num используется. Это поверхностная причина — основная проблема в том, что num может быть инвалидировано push. Rust ловит это потенциальное нарушение безопасности памяти.

Изменяемые ссылки предоставляют уникальный и невладеющий доступ к данным

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

Механизм для этого — изменяемые ссылки (также называемые уникальными ссылками). Вот простой пример изменяемой ссылки с accompanying изменениями разрешений:

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

Изменяемая ссылка создаётся оператором &mut. Тип num записывается как &mut i32. По сравнению с неизменяемыми ссылками, вы можете увидеть две важные разницы в разрешениях:

  1. Когда num была неизменяемой ссылкой, v всё ещё имел разрешение R. Теперь, когда num — изменяемая ссылка, v потерял все разрешения, пока num используется.
  2. Когда num была неизменяемой ссылкой, место *num имело только разрешение R. Теперь, когда num — изменяемая ссылка, *num также получил разрешение W.

Первое наблюдение делает изменяемые ссылки безопасными. Изменяемые ссылки позволяют мутацию, но предотвращают алиасинг. Заимствованное место v временно становится непригодным для использования, поэтому эффективно не является алиасом.

Второе наблюдение делает изменяемые ссылки полезными. v[2] может быть изменён через *num. Например, *num += 1 изменяет v[2]. Обратите внимание, что *num имеет разрешение W, но num — нет. num ссылается на саму изменяемую ссылку, например num не может быть переназначен на другую изменяемую ссылку.

Изменяемые ссылки также могут быть временно “понижены” до ссылок, доступных только для чтения. Например:

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

В этой программе заимствование &*num снимает разрешение W с *num, но не разрешение R, поэтому println!(..) может читать и *num, и *num2.

Разрешения возвращаются в конце времени жизни ссылки

Мы сказали выше, что ссылка изменяет разрешения, пока она “используется”. Фраза “используется” описывает время жизни ссылки, или диапазон кода от её рождения (где ссылка создаётся) до её смерти (последний раз(ы), когда ссылка используется).

Например, в этой программе время жизни y начинается с let y = &x и заканчивается с let z = *y:

Разрешение W на x возвращается x после окончания времени жизни y, как мы видели ранее.

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

Переменная c имеет разное время жизни в каждой ветви if-выражения. В then-блоке c используется в выражении c.to_ascii_uppercase(). Поэтому *v не восстанавливает разрешение W до после этой строки.

Однако в else-блоке c не используется. *v немедленно восстанавливает разрешение W при входе в else-блок.

Данные должны переживать все свои ссылки

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

Чтобы поймать такие ошибки, Rust использует разрешения, которые мы уже обсуждали. Заимствование &s снимает разрешение O с s. Однако drop ожидает разрешение O, что приводит к несоответствию разрешений.

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

Этот фрагмент вводит новый вид разрешения, разрешение потока F. Разрешение F ожидается всякий раз, когда выражение использует входную ссылку (как &strings[0]) или возвращает выходную ссылку (как return s_ref).

В отличие от разрешений RWO, F не меняется на протяжении тела функции. Ссылка имеет разрешение F, если ей разрешено использоваться (то есть протекать) в конкретном выражении. Например, давайте изменим first на новую функцию first_or, которая включает параметр default:

Эта функция больше не компилируется, потому что выражения &strings[0] и default не имеют необходимого разрешения F для возврата. Но почему? Rust даёт следующую ошибку:

error[E0106]: missing lifetime specifier
 --> test.rs:1:57
  |
1 | fn first_or(strings: &Vec<String>, default: &String) -> &String {
  |                      ------------           -------     ^ 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 `strings` or `default`

Сообщение “missing lifetime specifier” немного загадочно, но справочное сообщение даёт некоторый полезный контекст. Если Rust просто посмотрит на сигнатуру функции, он не знает, является ли выход &String ссылкой на strings или default. Чтобы понять, почему это важно, давайте скажем, что мы использовали first_or так:

fn main() {
    let strings = vec![];
    let default = String::from("default");
    let s = first_or(&strings, &default);
    drop(default);
    println!("{}", s);
}

Эта программа небезопасна, если first_or позволяет default протекать в возвращаемое значение. Как и в предыдущем примере, drop может инвалидировать s. Rust позволит компилировать эту программу только если он уверен, что default не может протекать в возвращаемое значение.

Чтобы указать, может ли default быть возвращён, Rust предоставляет механизм, называемый параметрами времени жизни. Мы объясним эту особенность позже в Главе 10.3, “Проверка ссылок с помощью времени жизни”. Пока достаточно знать, что: (1) входные/выходные ссылки обрабатываются иначе, чем ссылки внутри тела функции, и (2) Rust использует другой механизм, разрешение F, для проверки безопасности этих ссылок.

Чтобы увидеть разрешение F в другом контексте, скажем, вы попытались вернуть ссылку на переменную в стеке, вот так:

Эта программа небезопасна, потому что ссылка &s будет инвалидирована при возврате return_a_string. И Rust отклонит эту программу с похожей ошибкой “missing lifetime specifier”. Теперь вы можете понять, что эта ошибка означает, что s_ref отсутствует соответствующее разрешение потока.

Резюме

Ссылки предоставляют возможность читать и записывать данные без потребления владения ими. Ссылки создаются заимствованиями (& и &mut) и используются с разыменованиями (*), часто неявно.

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

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

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

Исправление ошибок владения

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

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

Исправление небезопасной программы: возврат ссылки на стек

Первый кейс — возврат ссылки на стек, как мы обсуждали в разделе “Данные должны переживать все ссылки на них”. Вот функция, которую мы рассматривали:

fn return_a_string() -> &String {
    let s = String::from("Hello world");
    &s
}

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

В зависимости от ситуации есть четыре способа продлить время жизни строки. Один — передать владение строкой из функции, изменив &String на String:

#![allow(unused)]
fn main() {
fn return_a_string() -> String {
    let s = String::from("Hello world");
    s
}
}

Другой вариант — вернуть строковый литерал, который живёт вечно (обозначается 'static). Это решение подходит, если строка никогда не будет изменяться, и тогда выделение в куче не нужно:

#![allow(unused)]
fn main() {
fn return_a_string() -> &'static str {
    "Hello world"    
}
}

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

#![allow(unused)]
fn main() {
use std::rc::Rc;
fn return_a_string() -> Rc<String> {
    let s = Rc::new(String::from("Hello world"));
    Rc::clone(&s)
}
}

Мы обсудим подсчёт ссылок подробнее в главе 15.4 Rc<T>, умный указатель с подсчётом ссылок”. Вкратце, Rc::clone клонирует только указатель на s, а не сами данные. Во время выполнения Rc проверяет, когда последний Rc, указывающий на данные, будет удалён, и затем освобождает данные.

Ещё один вариант — чтобы вызывающий код предоставил “слот” для строки с помощью изменяемой ссылки:

#![allow(unused)]
fn main() {
fn return_a_string(output: &mut String) {
    output.replace_range(.., "Hello world");
}
}

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

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

Исправление небезопасной программы: недостаточно прав

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

Эта программа отклоняется проверкой заимствований, потому что name — это неизменяемая ссылка, но name.push(..) требует права W. Эта программа небезопасна, потому что push может сделать недействительными другие ссылки на name за пределами stringify_name_with_title, например:

В этом примере ссылка first на name[0] создаётся до вызова stringify_name_with_title. Функция name.push(..) перераспределяет содержимое name, что делает first недействительной, вызывая чтение освобождённой памяти в println.

Так как исправить этот API? Одно простое решение — изменить тип name с &Vec<String> на &mut Vec<String>:

fn stringify_name_with_title(name: &mut Vec<String>) -> String {
    name.push(String::from("Esq."));
    let full = name.join(" ");
    full
}

Но это не хорошее решение! Функции не должны изменять свои входные данные, если вызывающий код не ожидает этого. Человек, вызывающий stringify_name_with_title, вероятно, не ожидает, что его вектор будет изменён этой функцией. Другая функция, например add_title_to_name, может ожидать изменения входных данных, но не наша.

Другой вариант — взять владение именем, изменив &Vec<String> на Vec<String>:

fn stringify_name_with_title(mut name: Vec<String>) -> String {
    name.push(String::from("Esq."));
    let full = name.join(" ");
    full
}

Но это тоже не хорошее решение! Очень редко функции в Rust берут владение над структурами данных в куче, такими как Vec и String. Эта версия stringify_name_with_title сделает входной name непригодным для использования, что очень неудобно для вызывающего кода, как мы обсуждали в начале раздела “Ссылки и заимствование”.

Таким образом, выбор &Vec на самом деле хорош, и мы не хотим его менять. Вместо этого мы можем изменить тело функции. Есть много возможных исправлений, которые различаются по объёму используемой памяти. Один вариант — клонировать входной name:

fn stringify_name_with_title(name: &Vec<String>) -> String {
    let mut name_clone = name.clone();
    name_clone.push(String::from("Esq."));
    let full = name_clone.join(" ");
    full
}

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

fn stringify_name_with_title(name: &Vec<String>) -> String {
    let mut full = name.join(" ");
    full.push_str(" Esq.");
    full
}

Это решение работает, потому что slice::join уже копирует данные из name в строку full.

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

Исправление небезопасной программы: псевдонимы и изменение структуры данных

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

Примечание: этот пример использует [итераторы] и [замыкания] для краткого нахождения ссылки на самую длинную строку. Мы обсудим эти возможности в следующих главах, а здесь дадим интуитивное представление о том, как они работают.

Эта программа отклоняется проверкой заимствований, потому что let largest = .. удаляет права W для dst. Однако dst.push(..) требует права W. Снова спросим: почему эта программа небезопасна? Потому что dst.push(..) может освободить содержимое dst, делая ссылку largest недействительной.

Чтобы исправить программу, ключевое понимание — нужно сократить время жизни largest, чтобы оно не перекрывалось с dst.push(..). Один вариант — клонировать largest:

#![allow(unused)]
fn main() {
fn add_big_strings(dst: &mut Vec<String>, src: &[String]) {
    let largest: String = dst.iter().max_by_key(|s| s.len()).unwrap().clone();
    for s in src {
        if s.len() > largest.len() {
            dst.push(s.clone());
        }
    }
}
}

Однако это может вызвать потерю производительности из-за выделения и копирования данных строки.

Другой вариант — выполнить все сравнения длин сначала, а затем изменить dst afterwards:

#![allow(unused)]
fn main() {
fn add_big_strings(dst: &mut Vec<String>, src: &[String]) {
    let largest: &String = dst.iter().max_by_key(|s| s.len()).unwrap();
    let to_add: Vec<String> = 
        src.iter().filter(|s| s.len() > largest.len()).cloned().collect();
    dst.extend(to_add);
}
}

Однако это также вызывает потерю производительности из-за выделения вектора to_add.

Последний вариант — скопировать длину largest, так как нам на самом деле не нужно содержимое largest, только его длина. Это решение, пожалуй, самое идиоматичное и производительное:

#![allow(unused)]
fn main() {
fn add_big_strings(dst: &mut Vec<String>, src: &[String]) {
    let largest_len: usize = dst.iter().max_by_key(|s| s.len()).unwrap().len();
    for s in src {
        if s.len() > largest_len {
            dst.push(s.clone());
        }
    }
}
}

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

Исправление небезопасной программы: копирование vs. перемещение из коллекции

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

Операция разыменования *n_ref ожидает только права R, которые есть у пути *n_ref. Но что произойдёт, если изменить тип элементов вектора с i32 на String? Тогда у нас больше не будет необходимых прав:

Первая программа скомпилируется, а вторая — нет. Rust выдаёт следующее сообщение об ошибке:

error[E0507]: cannot move out of `*s_ref` which is behind a shared reference
 --> test.rs:4:9
  |
4 | let s = *s_ref;
  |         ^^^^^^
  |         |
  |         move occurs because `*s_ref` has type `String`, which does not implement the `Copy` trait

Проблема в том, что вектор v владеет строкой “Hello world”. Когда мы разыменовываем s_ref, это пытается взять владение строкой из вектора. Но ссылки — это неуправляющие указатели; мы не можем взять владение через ссылку. Поэтому Rust жалуется, что мы “не можем переместить из […] общей ссылки”.

Но почему это небезопасно? Мы можем проиллюстрировать проблему, имитируя отклонённую программу:

Что происходит здесь — двойное освобождение. После выполнения let s = *s_ref и v, и s думают, что владеют “Hello world”. После удаления s строка “Hello world” освобождается. Затем удаляется v, и неопределённое поведение происходит при втором освобождении строки.

Примечание: после выполнения s = *s_ref нам даже не нужно использовать v или s, чтобы вызвать неопределённое поведение через двойное освобождение. Как только мы перемещаем строку из s_ref, неопределённое поведение произойдёт, как только элементы будут удалены.

Однако это неопределённое поведение не происходит, когда вектор содержит элементы i32. Разница в том, что копирование String копирует указатель на данные в куче. Копирование i32 этого не делает. В технических терминах Rust говорит, что тип i32 реализует типаж Copy, а String не реализует Copy (мы обсудим типажи в следующей главе).

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

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

Примечание: Одно исключение из этого правила — изменяемые ссылки. Например, &mut i32 — не копируемый тип. Так что если вы делаете что-то вроде:

let mut n = 0;
let a = &mut n;
let b = a;

Тогда a нельзя использовать после присвоения b. Это предотвращает одновременное использование двух изменяемых ссылок на одни и те же данные.

Итак, если у нас есть вектор не-Copy типов, таких как String, как безопасно получить доступ к элементу вектора? Вот несколько разных способов сделать это безопасно. Во-первых, можно избежать взятия владения строкой и просто использовать неизменяемую ссылку:

fn main() {
let v: Vec<String> = vec![String::from("Hello world")];
let s_ref: &String = &v[0];
println!("{s_ref}!");
}

Во-вторых, можно клонировать данные, если нужно получить владение строкой, оставив вектор нетронутым:

fn main() {
let v: Vec<String> = vec![String::from("Hello world")];
let mut s: String = v[0].clone();
s.push('!');
println!("{s}");
}

Наконец, можно использовать метод, такой как Vec::remove, чтобы переместить строку из вектора:

fn main() {
let mut v: Vec<String> = vec![String::from("Hello world")];
let mut s: String = v.remove(0);
s.push('!');
println!("{s}");
assert!(v.len() == 0);
}

Исправление безопасной программы: изменение разных полей кортежа

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

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

Инструкция let first = &name.0 заимствует name.0. Это заимствование удаляет права WO у name.0. Оно также удаляет права WO у name. (Например, нельзя передать name в функцию, принимающую значение типа (String, String).) Но name.1 по-прежнему сохраняет право W, поэтому операция name.1.push_str(...) допустима.

Однако Rust может потерять точное понимание, какие места заимствованы. Например, представим, что мы рефакторим выражение &name.0 в функцию get_first. Обратите внимание, как после вызова get_first(&name) Rust теперь удаляет право W у name.1:

Теперь мы не можем сделать name.1.push_str(..)! Rust вернёт эту ошибку:

error[E0502]: cannot borrow `name.1` as mutable because it is also borrowed as immutable
  --> test.rs:11:5
   |
10 |     let first = get_first(&name);
   |                           ----- immutable borrow occurs here
11 |     name.1.push_str(", Esq.");
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
12 |     println!("{first} {}", name.1);
   |                ----- immutable borrow later used here

Это странно, ведь программа была безопасна до редактирования. Сделанное изменение существенно не меняет поведение во время выполнения. Так почему важно, что мы поместили &name.0 в функцию?

Проблема в том, что Rust не смотрит на реализацию get_first при решении, что get_first(&name) должно заимствовать. Rust смотрит только на сигнатуру типа, которая просто говорит “некоторый String во входных данных заимствуется”. Rust консервативно решает, что тогда и name.0, и name.1 заимствуются, и устраняет права на запись и владение для обоих.

Помните, ключевая идея в том, что программа выше безопасна. В ней нет неопределённого поведения! Будущая версия Rust может быть достаточно умной, чтобы позволить ей скомпилироваться, но сегодня она отклоняется. Так как обойти проверку заимствований сегодня? Один вариант — встроить выражение &name.0, как в исходной программе. Другой вариант — отложить проверку заимствований на время выполнения с помощью [ячеек], что мы обсудим в будущих главах.

Исправление безопасной программы: изменение разных элементов массива

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

Проверка заимствований Rust не содержит отдельных мест для a[0], a[1] и так далее. Она использует одно место a[_], представляющее все индексы a. Rust делает это, потому что не всегда может определить значение индекса. Например, представьте более сложный сценарий:

let idx = a_complex_function();
let x = &mut a[idx];

Каково значение idx? Rust не будет угадывать, поэтому предполагает, что idx может быть чем угодно. Например, представим, что мы пытаемся прочитать из одного индекса массива, одновременно записывая в другой:

Однако Rust отклоняет эту программу, потому что a отдала свои права на чтение x. Сообщение об ошибке компилятора говорит то же самое:

error[E0502]: cannot borrow `a[_]` as immutable because it is also borrowed as mutable
 --> test.rs:4:9
  |
3 | let x = &mut a[1];
  |         --------- mutable borrow occurs here
4 | let y = &a[2];
  |         ^^^^^ immutable borrow occurs here
5 | *x += *y;
  | -------- mutable borrow later used here

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

fn main() {
let mut a = [0, 1, 2, 3];
let (a_l, a_r) = a.split_at_mut(2);
let x = &mut a_l[1];
let y = &a_r[0];
*x += *y;
}

Вы можете спросить, но как реализован split_at_mut? В некоторых библиотеках Rust, особенно в основных типах, таких как Vec или slice, вы часто найдёте unsafe блоки. unsafe блоки позволяют использовать “сырые” указатели, которые не проверяются на безопасность проверкой заимствований. Например, мы могли бы использовать unsafe-блок для выполнения нашей задачи:

fn main() {
let mut a = [0, 1, 2, 3];
let x = &mut a[1] as *mut i32;
let y = &a[2] as *const i32;
unsafe { *x += *y; } // НЕ ДЕЛАЙТЕ ЭТОГО, если не знаете, что делаете!
}

Небезопасный код иногда необходим для обхода ограничений проверки заимствований. Как общая стратегия, скажем, проверка заимствований отклоняет программу, которую вы считаете фактически безопасной. Тогда вам стоит искать функции стандартной библиотеки (такие как split_at_mut), содержащие unsafe блоки, которые решают вашу проблему. Мы обсудим небезопасный код подробнее в Главе 20. Пока просто имейте в виду, что небезопасный код — это то, как Rust реализует определённые иначе невозможные шаблоны.

Резюме

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


  1. Эта гарантия применяется к программам, написанным на “безопасном подмножестве” Rust. Если вы используете unsafe код или вызываете небезопасные компоненты (например, библиотеку на C), то должны проявить дополнительную осторожность, чтобы избежать неопределённого поведения.

Тип среза

Срезы позволяют ссылаться на непрерывную последовательность элементов в коллекции, а не на всю коллекцию. Срез — это особый вид ссылки, поэтому он является указателем, не владеющим данными.

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

fn first_word(s: &String) -> ?

Функция first_word принимает параметр &String. Нам не нужно владение строкой, так что это нормально. Но что возвращать? У нас нет способа описать часть строки. Однако можно вернуть индекс конца слова, обозначенный пробелом. Попробуем так, как показано в Листинге 4-7.

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

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Листинг 4-7: Функция first_word, возвращающая индекс байта в параметре String

Чтобы пройти по строке элемент за элементом и проверить, является ли значение пробелом, преобразуем String в массив байтов с помощью метода as_bytes:

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

Листинг 4-8: Сохранение результата вызова функции first_word и последующее изменение содержимого String

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

Нужно беспокоиться о том, что индекс в word выйдет из синхронизации с данными в s, — это утомительно и чревато ошибками! Управление этими индексами становится ещё более хрупким, если мы напишем функцию second_word. Её сигнатура должна выглядеть так:

fn second_word(s: &String) -> (usize, usize) {

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

К счастью, у Rust есть решение этой проблемы: строковые срезы.

Строковые срезы

Строковый срез — это ссылка на часть String, и он выглядит так:

В отличие от ссылки на весь String (как s2), hello — это ссылка на часть String, указанная дополнительным фрагментом [0..5]. Мы создаём срезы, используя диапазон в квадратных скобках, указывая [начальный_индекс..конечный_индекс], где начальный_индекс — это первая позиция в срезе, а конечный_индекс — на единицу больше последней позиции в срезе.

Срезы — особые виды ссылок, потому что они являются “толстыми” указателями, или указателями с метаданными. Здесь метаданные — это длина среза. Мы можем увидеть эти метаданные, изменив визуализацию, чтобы заглянуть внутрь структур данных Rust:

Обратите внимание, что переменные hello и world имеют оба поля ptr и len, которые вместе определяют подчёркнутые области строки в куче. Вы также можете увидеть здесь, как на самом деле выглядит String: строка — это вектор байтов (Vec<u8>), который содержит длину len и буфер buf с указателем ptr и ёмкостью cap.

Поскольку срезы являются ссылками, они также изменяют права на referenced данные. Например, обратите внимание, что при создании hello как среза s, s теряет права на запись и владение:

Синтаксис диапазонов

С синтаксисом диапазонов .. в Rust, если вы хотите начать с индекса ноль, можно опустить значение перед двумя точками. Другими словами, эти варианты равны:

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

let slice = &s[0..2];
let slice = &s[..2];
}

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

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

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..];
}

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

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

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];
}

Примечание: Границы индексов диапазона строкового среза должны приходиться на допустимые границы символов UTF-8. Если попытаться создать строковый срез в середине многобайтового символа, программа завершится с ошибкой. Для введения строковых срезов в этом разделе мы предполагаем только ASCII; более подробное обсуждение обработки UTF-8 находится в разделе “Хранение текста, закодированного в UTF-8, со строками” Главы 8.

Переписывание first_word со строковыми срезами

Учитывая всю эту информацию, перепишем 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, когда мы получили индекс конца первого слова, но затем очистили строку, так что наш индекс стал недействительным? Этот код был логически некорректным, но не показывал немедленных ошибок. Проблемы проявились бы позже, если бы мы продолжали использовать индекс первого слова с опустошённой строкой. Срезы делают эту ошибку невозможной и сообщают о проблеме в коде гораздо раньше. Например:

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

Вы видите, что вызов first_word теперь удаляет право на запись из s, что предотвращает вызов s.clear(). Вот ошибка компилятора:

$ 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);
}

Листинг 4-9: Улучшение функции first_word использованием строкового среза для типа параметра s

Если у нас есть строковый срез, мы можем передать его напрямую. Если у нас есть String, мы можем передать срез String или ссылку на String. Эта гибкость использует неявное преобразование разыменования, особенность, которую мы рассмотрим в разделе “Неявные преобразования разыменования с функциями и методами” Главы 15.

Определение функции, принимающей строковый срез вместо ссылки на String, делает наш API более общим и полезным без потери функциональности:

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

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // `first_word` works on slices of `String`s, whether partial or whole.
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word` also works on references to `String`s, which are equivalent
    // to whole slices of `String`s.
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word` works on slices of string literals, whether partial or
    // whole.
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

Другие срезы

Строковые срезы, как вы можете себе представить, специфичны для строк. Но есть и более общий тип среза. Рассмотрим этот массив:

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

Так же, как мы можем хотеть ссылаться на часть строки, мы можем хотеть ссылаться на часть массива. Мы сделаем это так:

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

let slice = &a[1..3];

assert_eq!(slice, &[2, 3]);
}

Этот срез имеет тип &[i32]. Он работает так же, как и строковые срезы, храня ссылку на первый элемент и длину. Вы будете использовать этот вид среза для всех sorts других коллекций. Мы подробно обсудим эти коллекции, когда будем говорить о векторах в Главе 8.

Краткое содержание

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

Краткое повторение: владение

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

Владение против сборки мусора

Чтобы понять контекст владения, стоит поговорить о сборке мусора (garbage collection). Большинство языков программирования используют сборщик мусора для управления памятью, например, Python, JavaScript, Java и Go. Сборщик мусора работает во время выполнения программы (по крайней мере, трассировочный сборщик). Он сканирует память, чтобы найти данные, которые больше не используются — то есть, запущенная программа больше не может получить доступ к этим данным из локальной переменной функции. Затем сборщик освобождает неиспользуемую память для последующего использования.

Ключевое преимущество сборщика мусора в том, что он предотвращает неопределённое поведение (например, использование освобождённой памяти), которое может происходить в C или C++. Сборка мусора также устраняет необходимость сложной системы типов для проверки неопределённого поведения, как в Rust. Однако у сборки мусора есть несколько недостатков. Один из очевидных недостатков — производительность, поскольку сборка мусора влечёт либо частые небольшие накладные расходы (для подсчёта ссылок, как в Python и Swift), либо редкие крупные накладные расходы (для трассировки, как во всех остальных языках с сборкой мусора).

Но другой, менее очевидный недостаток заключается в том, что сборка мусора может быть непредсказуемой. Для иллюстрации представьте, что мы реализуем тип Document, представляющий изменяемый список слов. Мы могли бы реализовать Document в языке с сборкой мусора, таком как Python, следующим образом:

class Document:     
    def __init__(self, words: List[str]):
        """Create a new document"""
        self.words = words

    def add_word(self, word: str):
        """Add a word to the document"""
        self.words.append(word)
        
    def get_words(self) -> List[str]:  
        """Get a list of all the words in the document"""
        return self.words

Вот один из способов использования этого класса Document, который создаёт документ d, копирует его в новый документ d2, а затем изменяет d2.

words = ["Hello"]
d = Document(words)

d2 = Document(d.get_words())
d2.add_word("world")

Рассмотрим два ключевых вопроса об этом примере:

  1. Когда массив words будет освобождён? Эта программа создала три указателя на один и тот же массив. Переменные words, d и d2 все содержат указатель на массив words, выделенный в куче. Поэтому Python освободит массив words только тогда, когда все три переменные выйдут из области видимости. В более общем случае, часто бывает трудно предсказать, где данные будут собраны сборщиком мусора, просто прочитав исходный код.

  2. Каково содержимое документа d? Поскольку d2 содержит указатель на тот же массив words, что и d, то d2.add_word("world") также изменяет документ d. Поэтому в этом примере слова в d — это ["Hello", "world"]. Это происходит потому, что d.get_words() возвращает изменяемую ссылку на массив words в d. Повсеместные неявные изменяемые ссылки могут легко привести к непредсказуемым ошибкам, когда структуры данных могут раскрывать свою внутреннюю реализацию1. Здесь, вероятно, не предполагалось, что изменение d2 может изменить d.

Эта проблема не уникальна для Python — вы можете столкнуться с подобным поведением в C#, Java, JavaScript и так далее. Фактически, у большинства языков программирования есть понятие указателей. Вопрос лишь в том, как язык предоставляет указатели программисту. Сборка мусора затрудняет определение, какая переменная указывает на какие данные. Например, было неочевидно, что d.get_words() производит указатель на данные внутри d.

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

#![allow(unused)]
fn main() {
type Document = Vec<String>;

fn new_document(words: Vec<String>) -> Document {
    words
}

fn add_word(this: &mut Document, word: String) {
    this.push(word);
}

fn get_words(this: &Document) -> &[String] {
    this.as_slice()
}
}

Этот API Rust отличается от API Python в нескольких ключевых моментах:

  • Функция new_document потребляет владение входным вектором words. Это означает, что Document владеет вектором слов. Вектор слов будет предсказуемо освобождён, когда его владеющий Document выйдет из области видимости.

  • Функция add_word требует изменяемую ссылку &mut Document для возможности изменения документа. Она также потребляет владение входным параметром word, что означает, что никто больше не может изменять отдельные слова документа.

  • Функция get_words возвращает явную неизменяемую ссылку на строки внутри документа. Единственный способ создать новый документ из этого вектора слов — глубоко скопировать его содержимое, вот так:

fn main() {
    let words = vec!["hello".to_string()];
    let d = new_document(words);

    // .to_vec() преобразует &[String] в Vec<String> путём клонирования каждой строки
    let words_copy = get_words(&d).to_vec();
    let mut d2 = new_document(words_copy);
    add_word(&mut d2, "world".to_string());

    // Изменение `d2` не влияет на `d`
    assert!(!get_words(&d).contains(&"world".into()));
}

Смысл этого примера в том: если Rust — не ваш первый язык, то у вас уже есть опыт работы с памятью и указателями! Rust просто делает эти концепции явными. Это даёт двойную выгоду: (1) повышение производительности во время выполнения за счёт отсутствия сборки мусора и (2) повышение предсказуемости за счёт предотвращения случайных “утечек” данных.

Концепции владения

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

Владение во время выполнения

Начнём с повторения того, как Rust использует память во время выполнения:

  • Rust размещает локальные переменные в кадрах стека, которые выделяются при вызове функции и освобождаются при завершении вызова.
  • Локальные переменные могут содержать либо данные (например, числа, булевы значения, кортежи и т.д.), либо указатели.
  • Указатели могут быть созданы либо через “ящики” (Box — указатели, владеющие данными в куче), либо через ссылки (невладеющие указатели).

Эта диаграмма иллюстрирует, как каждая концепция выглядит во время выполнения:

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

  • Почему a_box_stack_ref указывает на стек, в то время как a_box_heap_ref указывает на кучу?
  • Почему значение 2 больше не находится в куче на L2?
  • Почему a_num имеет значение 5 на L2?

Если вы хотите повторить “ящики” (Box), перечитайте Главу 4.1. Если вы хотите повторить ссылки, перечитайте Главу 4.2. Если вы хотите увидеть исследования случаев с “ящиками” и ссылками, перечитайте Главу 4.3.

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

Если вы хотите повторить срезы, перечитайте Главу 4.4.

Владение на этапе компиляции

Rust отслеживает разрешения R (чтение), W (запись) и O (владение) для каждой переменной. Rust требует, чтобы у переменной были соответствующие разрешения для выполнения заданной операции. В качестве базового примера, если переменная не объявлена как let mut, то у неё отсутствует разрешение W, и её нельзя изменять:

Разрешения переменной могут измениться, если она перемещена (moved) или заимствована (borrowed). Перемещение переменной с типом, не поддерживающим копирование (например, Box<T> или String), требует разрешений RO, и перемещение лишает переменную всех разрешений. Это правило предотвращает использование перемещённых переменных:

Если вы хотите повторить, как работают перемещения, перечитайте Главу 4.1.

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

Но изменение неизменяемой ссылки недопустимо:

И изменение неявно заимствованных данных недопустимо:

И перемещение данных из ссылки недопустимо:

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

Но доступ к неявно заимствованным данным недопустим:

Если вы хотите повторить разрешения и ссылки, перечитайте Главу 4.2.

Связь владения между этапом компиляции и выполнением

Разрешения Rust предназначены для предотвращения неопределённого поведения. Например, один вид неопределённого поведения — это использование после освобождения (use-after-free), когда освобождённая память читается или записывается. Неизменяемые заимствования удаляют разрешение W для предотвращения использования после освобождения, как в этом случае:

Другой вид неопределённого поведения — это двойное освобождение (double-free), когда память освобождается дважды. Разыменования ссылок на данные, не поддерживающие копирование, не имеют разрешения O для предотвращения двойных освобождений, как в этом случае:

Если вы хотите повторить неопределённое поведение, перечитайте Главу 4.1 и Главу 4.3.

Остальное о владении

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

И не забывайте пройти викторины, если хотите проверить своё понимание!


  1. Фактически, исходное изобретение типов владения вообще не было связано с безопасностью памяти. Оно было направлено на предотвращение утечек изменяемых ссылок на внутренности структур данных в языках, похожих на Java. Если вам интересно узнать больше об истории типов владения, ознакомьтесь со статьёй “Ownership Types for Flexible Alias Protection” (Clarke et al. 1998).

Использование структур для организации связанных данных

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

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

Определение и создание экземпляров структур

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

Чтобы определить структуру, мы вводим ключевое слово struct и называем всю структуру. Имя структуры должно описывать значимость группируемых вместе элементов данных. Затем, внутри фигурных скобок, мы определяем имена и типы элементов данных, которые называем полями. Например, на Листинге 5-1 показана структура, хранящая информацию об учётной записи пользователя.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {}
Listing 5-1: Определение структуры User

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

Чтобы получить конкретное значение из структуры, мы используем точечную нотацию. Например, чтобы получить адрес электронной почты этого пользователя, мы используем user1.email. Если экземпляр изменяемый, мы можем изменить значение, используя точечную нотацию и присваивание конкретному полю. Листинг 5-3 показывает, как изменить значение в поле email изменяемого экземпляра User.

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

Листинг 5-4 показывает функцию build_user, которая возвращает экземпляр User с заданным адресом электронной почты и именем пользователя. Поле active получает значение true, а sign_in_count получает значение 1.

Filename: 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"),
    );
}
Listing 5-4: Функция build_user, которая принимает адрес электронной почты и имя пользователя и возвращает экземпляр User

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

Использование сокращённой инициализации полей

Поскольку имена параметров и имена полей структуры в Листинге 5-4 точно совпадают, мы можем использовать синтаксис сокращённой инициализации полей, чтобы переписать build_user так, чтобы она вела себя точно так же, но без повторения username и email, как показано на Листинге 5-5.

Filename: 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"),
    );
}
Listing 5-5: Функция build_user, которая использует сокращённую инициализацию полей, поскольку параметры username и email имеют те же имена, что и поля структуры

Здесь мы создаём новый экземпляр структуры User, у которой есть поле с именем email. Мы хотим установить значение поля email равным значению параметра email функции build_user. Поскольку поле email и параметр email имеют одинаковые имена, нам нужно написать только email, а не email: email.

Создание экземпляров из других экземпляров с помощью синтаксиса обновления структур

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

Сначала на Листинге 5-6 мы покажем, как создать новый экземпляр User в user2 обычным способом, без синтаксиса обновления. Мы устанавливаем новое значение для email, но в остальном используем те же значения из user1, которые мы создали на Листинге 5-2.

Листинг 5-6: Создание нового экземпляра User с использованием всех, кроме одного, значений из user1

Используя синтаксис обновления структур, мы можем достичь того же эффекта с меньшим количеством кода, как показано на Листинге 5-7. Синтаксис .. указывает, что оставшиеся поля, не установленные явно, должны иметь те же значения, что и поля в заданном экземпляре.

Filename: 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
    };
}
Listing 5-7: Использование синтаксиса обновления структур для установки нового значения email для экземпляра User, но использования остальных значений из user1

Код на Листинге 5-7 также создаёт экземпляр в user2, у которого другое значение для email, но те же значения для полей username, active и sign_in_count из user1. ..user1 должен идти последним, чтобы указать, что любые оставшиеся поля должны получать свои значения из соответствующих полей в user1, но мы можем выбрать установку значений для любого количества полей в любом порядке, независимо от порядка полей в определении структуры.

Обратите внимание, что синтаксис обновления структур использует =, как присваивание; это происходит потому, что он перемещает данные, как мы видели в разделе “Что такое владение?”. В этом примере после создания user2 user1 частично становится недействительным, потому что String в поле username user1 была перемещена в user2. Если бы мы дали user2 новые значения String как для email, так и для username, и, таким образом, использовали только значения active и sign_in_count из user1, то user1 оставался бы полностью действительным после создания user2. Типы active и sign_in_count — это типы, которые реализуют типаж Copy, поэтому поведение, которое мы обсудили в разделе “Копирование против перемещения из коллекции”, применилось бы.

Использование кортежных структур без именованных полей для создания разных типов

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

Чтобы определить кортежную структуру, начните с ключевого слова struct и имени структуры, за которым следуют типы в кортеже. Например, здесь мы определяем и используем две кортежные структуры с именами Color и Point:

Filename: src/main.rs

Обратите внимание, что значения black и origin имеют разные типы, потому что они являются экземплярами разных кортежных структур. Каждая определяемая вами структура является своим собственным типом, даже если поля внутри структуры могут иметь одинаковые типы. Например, функция, которая принимает параметр типа Color, не может принять Point в качестве аргумента, даже если оба типа состоят из трёх значений i32. В остальном экземпляры кортежных структур похожи на кортежи в том смысле, что вы можете деструктурировать их на отдельные части, и вы можете использовать . followed by the index to access an individual value. В отличие от кортежей, кортежные структуры требуют, чтобы вы указывали имя типа структуры при их деструктуризации. Например, мы бы написали let Point(x, y, z) = origin;, чтобы деструктурировать значения в точке origin в переменные с именами x, y и z.

Структуры, подобные единичному типу, без каких-либо полей

Вы также можете определить структуры, которые не имеют никаких полей! Они называются структурами, подобными единичному типу, потому что они ведут себя аналогично (), единичному типу, который мы упоминали в разделе “Тип кортежа”. Структуры, подобные единичному типу, могут быть полезны, когда вам нужно реализовать типаж для некоторого типа, но у вас нет данных, которые вы хотите хранить в самом типе. Мы обсудим типажи в Главе 10. Вот пример объявления и создания экземпляра структуры, подобной единичному типу, с именем AlwaysEqual:

Чтобы определить AlwaysEqual, мы используем ключевое слово struct, желаемое имя, а затем точку с запятой. Не нужны фигурные скобки или круглые скобки! Затем мы можем получить экземпляр AlwaysEqual в переменной subject аналогичным образом: используя определённое нами имя, без каких-либо фигурных скобок или круглых скобок. Представьте, что позже мы определим поведение для этого типа так, чтобы каждый экземпляр AlwaysEqual всегда был равен каждому экземпляру любого другого типа, возможно, для получения известного результата для целей тестирования. Нам не понадобились бы никакие данные для реализации такого поведения! Вы увидите в Главе 10, как определить типажи и реализовать их для любого типа, включая структуры, подобные единичному типу.

Владение данными структур

В определении структуры User на Листинге 5-1 мы использовали владеющий тип String вместо типа среза строки &str. Это сознательный выбор, потому что мы хотим, чтобы каждый экземпляр этой структуры владел всеми своими данными и чтобы эти данные были действительны так долго, как действительна вся структура.

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

Filename: 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` (bin "structs") due to 2 previous errors

В Главе 10 мы обсудим, как исправить эти ошибки, чтобы вы могли хранить ссылки в структурах, но пока мы будем исправлять подобные ошибки, используя владеющие типы, такие как String, вместо ссылок, таких как &str.

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

Подобно нашему обсуждению в “Разные поля кортежа”, проверка заимствований Rust будет отслеживать разрешения владения как на уровне структуры, так и на уровне поля. Например, если мы заимствуем поле x структуры Point, то и p, и p.x временно теряют свои разрешения (но не p.y):

В результате, если мы попытаемся использовать p, пока p.x заимствован изменяемо, как здесь:

Тогда компилятор отклонит нашу программу со следующей ошибкой:

error[E0502]: cannot borrow `p` as immutable because it is also borrowed as mutable
  --> test.rs:10:17
   |
9  |     let x = &mut p.x;
   |             -------- mutable borrow occurs here
10 |     print_point(&p);
   |                 ^^ immutable borrow occurs here
11 |     *x += 1;
   |     ------- mutable borrow later used here

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

Пример программы с использованием структур

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

Создадим новый бинарный проект Cargo с именем rectangles. Оно будет принимать ширину и высоту прямоугольника в пикселях и вычислять его площадь. Листинг 5-8 показывает короткую программу, которая делает это в файле src/main.rs нашего проекта.

Filename: 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
}
Listing 5-8: Вычисление площади прямоугольника, заданного отдельными переменными ширины и высоты

Теперь запустите эту программу с помощью 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 показывает другую версию нашей программы, которая использует кортежи.

Filename: 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
}
Listing 5-9: Задание ширины и высоты прямоугольника с помощью кортежа

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

Перепутать ширину и высоту не важно для вычисления площади, но если мы хотим нарисовать прямоугольник на экране, это будет иметь значение! Нам придётся помнить, что width — это индекс кортежа 0, а height — индекс 1. Это было бы ещё сложнее для кого-то другого понять и запомнить, если бы они использовали наш код. Поскольку мы не передаём смысл наших данных в коде, теперь легче допустить ошибки.

Рефакторинг со структурами: добавление большего смысла

Мы используем структуры, чтобы добавить смысл, помечая данные. Мы можем преобразовать используемый нами кортеж в структуру с именем для целого, а также с именами для частей, как показано в Листинге 5-10.

Filename: 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
}
Listing 5-10: Определение структуры Rectangle

Здесь мы определили структуру и назвали её Rectangle. Внутри фигурных скобок мы определили поля как width и height, оба имеют тип u32. Затем в main мы создали конкретный экземпляр Rectangle, который имеет ширину 30 и высоту 50.

Наша функция area теперь определена с одним параметром, который мы назвали rectangle, тип которого — неизменяемое заимствование экземпляра структуры Rectangle. Как упоминалось в главе 4, мы хотим заимствовать структуру, а не принимать её владение. Таким образом, main сохраняет своё владение и может продолжать использовать rect1, что является причиной, по которой мы используем & в сигнатуре функции и при вызове функции.

Функция area обращается к полям width и height экземпляра Rectangle (обратите внимание, что обращение к полям заимствованного экземпляра структуры не перемещает значения полей, поэтому вы часто видите заимствования структур). Сигнатура нашей функции для area теперь говорит именно то, что мы имеем в виду: вычислить площадь Rectangle, используя его поля width и height. Это передаёт, что ширина и высота связаны друг с другом, и даёт описательные имена значениям, а не использует значения индексов кортежа 0 и 1. Это победа для ясности.

Добавление полезной функциональности с помощью производных типажей

Было бы полезно иметь возможность вывести экземпляр Rectangle во время отладки нашей программы и увидеть значения всех его полей. Листинг 5-11 пытается использовать макрос println!, как мы использовали его в предыдущих главах. Однако это не сработает.

Filename: src/main.rs
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {rect1}");
}
Listing 5-11: Попытка вывести экземпляр Rectangle

При компиляции этого кода мы получаем ошибку с основным сообщением:

error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`

Макрос println! может делать многие виды форматирования, и по умолчанию фигурные скобки говорят println! использовать форматирование, известное как Display: вывод, предназначенный для непосредственного потребления конечным пользователем. Примитивные типы, которые мы видели до сих пор, реализуют Display по умолчанию, потому что существует только один способ показать 1 или любой другой примитивный тип пользователю. Но со структурами то, как println! должен форматировать вывод, менее ясно, потому что существует больше возможностей отображения: нужны ли запятые? Нужно ли выводить фигурные скобки? Должны ли все поля быть показаны? Из-за этой неоднозначности Rust не пытается угадать, что мы хотим, и у структур нет предоставляемой реализации Display для использования с println! и заполнителем {}.

Если мы продолжим читать ошибки, мы найдём эту полезную заметку:

   = help: the trait `std::fmt::Display` is not implemented for `Rectangle`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead

Попробуем! Вызов макроса println! теперь будет выглядеть как println!("rect1 is {rect1:?}");. Помещение спецификатора :? внутрь фигурных скобок говорит println!, что мы хотим использовать формат вывода под названием Debug. Типаж Debug позволяет нам вывести нашу структуру таким образом, который полезен для разработчиков, чтобы мы могли увидеть её значение во время отладки нашего кода.

Скомпилируйте код с этим изменением. Чёрт! Мы всё ещё получаем ошибку:

error[E0277]: `Rectangle` doesn't implement `Debug`

Но снова компилятор даёт нам полезную заметку:

   = help: the trait `Debug` is not implemented for `Rectangle`
   = note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`

Rust всё же включает функциональность для вывода отладочной информации, но мы должны явно согласиться, чтобы сделать эту функциональность доступной для нашей структуры. Чтобы это сделать, мы добавляем внешний атрибут #[derive(Debug)] непосредственно перед определением структуры, как показано в Листинге 5-12.

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {rect1:?}");
}
Listing 5-12: Добавление атрибута для вывода типажа Debug и вывод экземпляра Rectangle с использованием отладочного форматирования

Теперь, когда мы запускаем программу, мы не получим ошибок и увидим следующий вывод:

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

Мы видим, что первая часть вывода пришла из файла src/main.rs строки 10, где мы отлаживаем выражение 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 и Главе 18 соответственно), и их первый параметр всегда self, который представляет экземпляр структуры, для которого вызывается метод.

Определение методов

Давайте изменим функцию area, которая принимает экземпляр Rectangle в качестве параметра, и вместо этого сделаем метод area, определённый для структуры Rectangle, как показано в Листинге 5-13.

Filename: 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()
    );
}
Listing 5-13: Определение метода area для структуры Rectangle

Чтобы определить функцию в контексте Rectangle, мы начинаем блок impl (реализации) для 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:

Filename: 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.

Методы с дополнительными параметрами

Давайте потренируемся в использовании методов, реализовав второй метод для структуры Rectangle. На этот раз мы хотим, чтобы экземпляр Rectangle принимал другой экземпляр Rectangle и возвращал true, если второй Rectangle может полностью поместиться внутри self (первого Rectangle); в противном случае он должен вернуть false. То есть, как только мы определим метод can_hold, мы хотим иметь возможность написать программу, показанную в Листинге 5-14.

Filename: 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));
}
Listing 5-14: Использование ещё не написанного метода can_hold

Ожидаемый вывод будет выглядеть следующим образом, потому что обе размерности rect2 меньше размерностей rect1, но rect3 шире, чем rect1:

Can rect1 hold rect2? true
Can rect1 hold rect3? false

Мы знаем, что хотим определить метод, поэтому он будет внутри блока impl Rectangle. Имя метода будет can_hold, и он будет принимать неизменяемую ссылку на другой Rectangle в качестве параметра. Мы можем определить тип параметра, посмотрев на код, который вызывает метод: rect1.can_hold(&rect2) передаёт &rect2, что является неизменяемой ссылкой на rect2, экземпляр Rectangle. Это имеет смысл, потому что нам нужно только читать rect2 (а не записывать, что означало бы необходимость изменяемой ссылки), и мы хотим, чтобы main сохранил владение rect2, чтобы мы могли использовать его снова после вызова метода can_hold. Возвращаемое значение can_hold будет логическим, а реализация проверит, что ширина и высота self больше, чем ширина и высота другого Rectangle соответственно. Давайте добавим новый метод can_hold в блок impl из Листинга 5-13, показанный в Листинге 5-15.

Filename: 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));
}
Listing 5-15: Реализация метода can_hold на Rectangle, который принимает другой экземпляр Rectangle в качестве параметра

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

Ассоциированные функции

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

Ассоциированные функции, которые не являются методами, часто используются для конструкторов, которые будут возвращать новый экземпляр структуры. Их часто называют new, но new — это не специальное имя и не встроено в язык. Например, мы могли бы предоставить ассоциированную функцию с именем square, которая бы имела один параметр размерности и использовала его как для ширины, так и для высоты, тем самым упрощая создание квадратного Rectangle, вместо того чтобы указывать одно и то же значение дважды:

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

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn square(size: u32) -> Self {
        Self {
            width: size,
            height: size,
        }
    }
}

fn main() {
    let sq = Rectangle::square(3);
}

Ключевые слова Self в возвращаемом типе и в теле функции являются псевдонимами для типа, который появляется после ключевого слова impl, который в данном случае — Rectangle.

Чтобы вызвать эту ассоциированную функцию, мы используем синтаксис :: с именем структуры; let sq = Rectangle::square(3); — это пример. Эта функция пространства имён структуры: синтаксис :: используется как для ассоциированных функций, так и для пространств имён, создаваемых модулями. Мы обсудим модули в Главе 7.

Несколько блоков impl

Каждой структуре разрешено иметь несколько блоков impl. Например, Листинг 5-15 эквивалентен коду, показанному в Листинге 5-16, где каждый метод находится в своём блоке impl.

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}
Listing 5-16: Переписывание Листинга 5-15 с использованием нескольких блоков impl

Здесь нет причины разделять эти методы на несколько блоков impl, но это допустимый синтаксис. Мы увидим случай, когда несколько блоков impl полезны, в Главе 10, где мы обсудим обобщённые типы и типажи.

Вызовы методов — это синтаксический сахар для вызовов функций

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

struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn set_width(&mut self, width: u32) {
        self.width = width;
    }
}

И предположим, что у нас есть прямоугольник r. Тогда вызовы методов r.area() и r.set_width(2) эквивалентны этому:

struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
       self.width * self.height
     }

    fn set_width(&mut self, width: u32) {
        self.width = width;
    }
}

fn main() {
    let mut r = Rectangle { 
        width: 1,
        height: 2
    };
    let area1 = r.area();
    let area2 = Rectangle::area(&r);
    assert_eq!(area1, area2);

    r.set_width(2);
    Rectangle::set_width(&mut r, 2);
}

Вызов метода r.area() становится Rectangle::area(&r). Имя функции — это ассоциированная функция Rectangle::area. Аргумент функции — это параметр &self. Rust автоматически вставляет оператор заимствования &.

Примечание: если вы знакомы с C или C++, вы привыкли к двум разным синтаксисам для вызовов методов: r.area() и r->area(). У Rust нет эквивалента оператору стрелки ->. Rust автоматически ссылается и разыменовывает получатель метода при использовании оператора точки.

Вызов метода r.set_width(2) аналогично становится Rectangle::set_width(&mut r, 2). Этот метод ожидает &mut self, поэтому первый аргумент — изменяемая ссылка &mut r. Второй аргумент точно такой же, число 2.

Как мы описали в Главе 4.2 “Разыменование указателя даёт доступ к его данным”, Rust вставит столько ссылок и разыменований, сколько нужно, чтобы типы совпали для параметра self. Например, вот два эквивалентных вызова area для изменяемой ссылки на коробчатый прямоугольник:

struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
       self.width * self.height
     }

    fn set_width(&mut self, width: u32) {
        self.width = width;
    }
}
fn main() {
    let r = &mut Box::new(Rectangle { 
        width: 1,
        height: 2
    });
    let area1 = r.area();
    let area2 = Rectangle::area(&**r);
    assert_eq!(area1, area2);
}

Rust добавит два разыменования (один для изменяемой ссылки, один для коробки), а затем одну неизменяемую ссылку, потому что area ожидает &Rectangle. Обратите внимание, что это также ситуация, когда изменяемая ссылка “понижается” до общей ссылки, как мы обсуждали в Главе 4.2. И наоборот, вам не будет разрешено вызвать set_width на значении типа &Rectangle или &Box<Rectangle>.

Методы и владение

Как мы обсуждали в Главе 4.2 “Ссылки и заимствование”, методы должны вызываться для структур, которые имеют необходимые права. В качестве работающего примера мы будем использовать эти три метода, которые принимают &self, &mut self и self соответственно.

impl Rectangle {    
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn set_width(&mut self, width: u32) {
        self.width = width;
    }

    fn max(self, other: Rectangle) -> Rectangle {
        Rectangle { 
            width: self.width.max(other.width),
            height: self.height.max(other.height),
        }
    }
}

Чтение и запись с &self и &mut self

Если мы создаём владеющий прямоугольник с let rect = Rectangle { ... }, то rect имеет права R и O. С этими правами допустимо вызвать методы area и max:

Однако, если мы попытаемся вызвать set_width, у нас не хватает права W:

Rust отклонит эту программу с соответствующим сообщением об ошибке:

error[E0596]: cannot borrow `rect` as mutable, as it is not declared as mutable
  --> test.rs:28:1
   |
24 | let rect = Rectangle {
   |     ---- help: consider changing this to be mutable: `mut rect`
...
28 | rect.set_width(0);
   | ^^^^^^^^^^^^^^^^^ cannot borrow as mutable

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

Перемещения с self

Вызов метода, который ожидает self, переместит входную структуру (если только структура не реализует Copy). Например, мы не можем использовать Rectangle после передачи его в max:

Как только мы вызываем rect.max(..), мы перемещаем rect и теряем все права на него. Попытка скомпилировать эту программу даст нам следующую ошибку:

error[E0382]: borrow of moved value: `rect`
  --> test.rs:33:16
   |
24 | let rect = Rectangle {
   |     ---- move occurs because `rect` has type `Rectangle`, which does not implement the `Copy` trait
...
32 | let max_rect = rect.max(other_rect);
   |                     --------------- `rect` moved due to this method call
33 | println!("{}", rect.area());
   |                ^^^^^^^^^^^ value borrowed here after move

Аналогичная ситуация возникает, если мы пытаемся вызвать метод self на ссылку. Например, предположим, что мы попытались сделать метод set_to_max, который присваивает self результату self.max(..):

Тогда мы можем видеть, что self не имеет прав O в операции self.max(..). Поэтому Rust отклоняет эту программу со следующим сообщением об ошибке:

error[E0507]: cannot move out of `*self` which is behind a mutable reference
  --> test.rs:23:17
   |
23 |         *self = self.max(other);
   |                 ^^^^^----------
   |                 |    |
   |                 |    `*self` moved due to this method call
   |                 move occurs because `*self` has type `Rectangle`, which does not implement the `Copy` trait
   |

Это тот же тип ошибки, который мы обсуждали в Главе 4.3 “Копирование против перемещения из коллекции”.

Хорошие перемещения и плохие перемещения

Вы можете спросить: почему имеет значение, перемещаем ли мы из *self? На самом деле, для случая Rectangle это безопасно перемещать из *self, хотя Rust не позволяет вам этого сделать. Например, если мы смоделируем программу, которая вызывает отклонённый set_to_max, вы можете видеть, что ничего небезопасного не происходит:

Причина, по которой безопасно перемещать из *self, заключается в том, что Rectangle не владеет данными в куче. На самом деле, мы можем заставить Rust скомпилировать set_to_max, просто добавив #[derive(Copy, Clone)] к определению Rectangle:

Обратите внимание, что в отличие от предыдущего случая, self.max(other) больше не требует права O на *self или other. Помните, что self.max(other) раскрывается в Rectangle::max(*self, other). Разыменование *self не требует владения над *self, если Rectangle копируем.

Вы можете спросить: почему Rust не автоматически выводит Copy для Rectangle? Rust не автоматически выводит Copy для стабильности при изменениях API. Представьте, что автор типа Rectangle решил добавить поле name: String. Тогда весь клиентский код, который полагается на то, что Rectangle является Copy, внезапно будет отклонён компилятором. Чтобы избежать этой проблемы, авторы API должны явно добавить #[derive(Copy)], чтобы указать, что они ожидают, что их структура всегда будет Copy.

Чтобы лучше понять проблему, давайте запустим симуляцию. Допустим, мы добавили name: String в Rectangle. Что произойдёт, если Rust разрешит компиляцию set_to_max?

В этой программе мы вызываем set_to_max с двумя прямоугольниками r1 и r2. self — это изменяемая ссылка на r1, а other — это перемещение r2. После вызова self.max(other) метод max потребляет владение обоими прямоугольниками. Когда max возвращается, Rust освобождает обе строки “r1” и “r2” в куче. Обратите внимание на проблему: в месте L2 *self должен быть читаемым и записываемым. Однако (*self).name (фактически r1.name) была освобождена.

Поэтому, когда мы делаем *self = max, мы сталкиваемся с неопределённым поведением. Когда мы перезаписываем *self, Rust неявно удалит данные, которые ранее были в *self. Чтобы сделать это поведение явным, мы добавили drop(*self). После вызова drop(*self) Rust пытается освободить (*self).name во второй раз. Это действие — двойное освобождение, что является неопределённым поведением.

Поэтому помните: когда вы видите ошибку вроде “cannot move out of *self”, это обычно потому, что вы пытаетесь вызвать метод self на ссылку, такую как &self или &mut self. Rust защищает вас от двойного освобождения.

Итоги

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

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

Перечисления и сопоставление с образцом

В этой главе мы рассмотрим перечисления (enums). Перечисления позволяют определить тип, перечислив его возможные варианты. Сначала мы определим и используем перечисление, чтобы показать, как оно может кодировать значение вместе с данными. Затем мы изучим особенно полезное перечисление Option, которое выражает, что значение может быть либо чем-то, либо ничем. Далее мы посмотрим, как сопоставление с образцом в выражении match позволяет легко выполнять разный код для разных вариантов перечисления. Наконец, мы рассмотрим, как конструкция if let является ещё одним удобным и кратким идиоматическим способом работы с перечислениями в вашем коде.

Определение перечисления

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

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

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

Мы можем выразить эту концепцию в коде, определив перечисление IpAddrKind и перечислив возможные виды IP-адреса: V4 и V6. Это варианты перечисления:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

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

Значения перечисления

Мы можем создать экземпляры каждого из двух вариантов IpAddrKind так:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

Обратите внимание, что варианты перечисления находятся в пространстве имён под его идентификатором, и мы используем двойное двоеточие для разделения. Это полезно, потому что теперь оба значения IpAddrKind::V4 и IpAddrKind::V6 имеют один и тот же тип: IpAddrKind. Затем мы, например, можем определить функцию, которая принимает любой IpAddrKind:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

И мы можем вызвать эту функцию с любым вариантом:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

Использование перечислений имеет ещё больше преимуществ. Подумав о нашем типе IP-адреса, в данный момент у нас нет способа хранить фактические данные IP- адреса; мы знаем только его вид. Учитывая, что вы только что узнали о структурах в главе 5, вас может соблазнить решить эту проблему с помощью структур, как показано в листинге 6-1.

Здесь мы определили структуру IpAddr, которая имеет два поля: поле kind типа IpAddrKind (перечисление, которое мы определили ранее) и поле address типа String. У нас есть два экземпляра этой структуры. Первый — home, и у него значение IpAddrKind::V4 в качестве kind с связанными данными адреса 127.0.0.1. Второй экземпляр — loopback. У него другой вариант IpAddrKind в качестве значения kind, V6, и связанный с ним адрес ::1. Мы использовали структуру, чтобы сгруппировать значения kind и address, теперь вариант связан со значением.

Однако представление той же концепции с помощью только перечисления более лаконично: вместо перечисления внутри структуры мы можем поместить данные непосредственно в каждый вариант перечисления. Это новое определение перечисления IpAddr говорит, что оба варианта V4 и V6 будут иметь связанные значения String:

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

Есть ещё одно преимущество использования перечисления вместо структуры: каждый вариант может иметь разные типы и количество связанных данных. IP-адреса четвёртой версии всегда будут иметь четыре числовых компонента со значениями от 0 до 255. Если бы мы хотели хранить адреса V4 как четыре значения u8, но по-прежнему выражать адреса V6 как одно значение String, мы не смогли бы этого сделать со структурой. Перечисления легко справляются с этим случаем:

Мы показали несколько разных способов определения структур данных для хранения 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() {}
Listing 6-2: Перечисление Message, варианты которого хранят разные объёмы и типы значений

Это перечисление имеет четыре варианта с разными типами:

  • 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 или не-null.

В своей презентации 2009 года «Null References: The Billion Dollar Mistake» («Пустые ссылки: миллиардная ошибка») Тони Хоар, изобретатель null, сказал следующее:

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

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

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

Проблема не столько в концепции, сколько в конкретной реализации. Поэтому Rust не имеет null, но у него есть перечисление, которое может кодировать концепцию присутствия или отсутствия значения. Это перечисление — Option<T>, и оно определяется стандартной библиотекой следующим образом:

#![allow(unused)]
fn main() {
enum Option<T> {
    None,
    Some(T),
}
}

Перечисление Option<T> настолько полезно, что оно даже включено в прелюдию; вам не нужно явно вводить его в область видимости. Его варианты также включены в прелюдию: вы можете использовать Some и None напрямую без префикса Option::. Option<T> по-прежнему просто обычное перечисление, и Some(T) и None — это всё ещё варианты типа Option<T>.

Синтаксис <T> — это особенность Rust, о которой мы ещё не говорили. Это обобщённый параметр типа, и мы подробнее рассмотрим обобщения в главе 10. Пока всё, что вам нужно знать, это то, что <T> означает, что вариант Some перечисления Option может хранить один фрагмент данных любого типа, и что каждый конкретный тип, который используется вместо T, делает общий тип Option<T> другим типом. Вот несколько примеров использования значений Option для хранения числовых типов и типов символов:

Тип some_numberOption<i32>. Тип some_charOption<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>`:
            `&i8` implements `Add<i8>`
            `&i8` implements `Add`
            `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 в Rust, компилятор обеспечит, чтобы у нас всегда было действительное значение. Мы можем действовать уверенно, не проверяя null перед использованием этого значения. Только когда у нас есть Option<i8> (или любой другой тип значения, с которым мы работаем), мы должны беспокоиться о возможном отсутствии значения, и компилятор убедится, что мы обработаем этот случай перед использованием значения.

Другими словами, вы должны преобразовать 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>, вы хотите иметь код, который будет обрабатывать каждый вариант. Вы хотите, чтобы некоторый код выполнялся только при наличии значения Some(T), и этот код может использовать внутреннее T. Вы хотите, чтобы другой код выполнялся только при наличии значения None, и у этого кода нет доступного значения T. Выражение match — это конструкт управления потоком выполнения, который делает именно это при использовании с перечислениями: оно будет выполнять разный код в зависимости от того, какой вариант перечисления у него есть, и этот код может использовать данные внутри сопоставляемого значения.

Конструкция управления потоком match

В Rust есть чрезвычайно мощная конструкция управления потоком под названием match, которая позволяет сравнивать значение с серией образцов и затем выполнять код в зависимости от того, какой образец совпал. Образцы могут состоять из литералов, имён переменных, шаблонов по умолчанию и многих других элементов; все виды образцов и их назначение описаны в Главе 19. Мощь 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() {}
Listing 6-3: Перечисление и выражение match, где варианты перечисления используются в качестве образцов

Разберём match в функции value_in_cents. Сначала указываем ключевое слово match, за которым следует выражение — в данном случае значение coin. Это похоже на условное выражение с if, но есть важное отличие: в if условие должно оцениваться в логическое значение, а здесь может быть любой тип. Тип coin в этом примере — перечисление Coin, которое мы определили в первой строке.

Далее идут ветви match. Каждая ветвь состоит из двух частей: образца и кода. Первая ветвь имеет образец — значение Coin::Penny — и оператор =>, который разделяет образец и код для выполнения. Код в этом случае — просто значение 1. Каждую ветвь отделяет от следующей запятая.

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

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

Мы обычно не используем фигурные скобки, если код ветви короткий, как в Листинге 6-3, где каждая ветвь просто возвращает значение. Если нужно выполнить несколько строк кода в ветви match, необходимо использовать фигурные скобки, а запятая после ветви тогда необязательна. Например, следующий код выводит «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 год в США чеканили четвертаки с разными дизайнами 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() {}
Listing 6-4: Перечисление Coin, в котором вариант Quarter также содержит значение UsState

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

В выражении match для этого кода мы добавляем переменную с именем state в образец, который совпадает со значениями варианта Coin::Quarter. Когда Coin::Quarter совпадает, переменная state привяжется к значению штата этого четвертака. Затем мы можем использовать state в коде для этой ветви, например:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("State quarter from {state:?}!");
            25
        }
    }
}

fn main() {
    value_in_cents(Coin::Quarter(UsState::Alaska));
}

Если бы мы вызвали value_in_cents(Coin::Quarter(UsState::Alaska)), coin был бы Coin::Quarter(UsState::Alaska). Когда мы сравниваем это значение с каждой ветвью match, ни одна не совпадёт, пока мы не дойдём до 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);
}
Listing 6-5: Функция, использующая выражение match для Option<i32>

Рассмотрим первый вызов plus_one более подробно. Когда мы вызываем plus_one(five), переменная x в теле plus_one будет иметь значение Some(5). Затем мы сравниваем его с каждой ветвью match:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Значение Some(5) не совпадает с образцом None, поэтому переходим к следующей ветви:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Совпадает ли Some(5) с Some(i)? Да! У нас одинаковый вариант. i привязывается к значению, contained в Some, поэтому i принимает значение 5. Затем выполняется код в ветви match, поэтому мы добавляем 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 справа от =>. Поскольку первая ветвь совпала, никакие другие ветви не сравниваются.

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

Соответствия исчерпывающи

Есть ещё один аспект 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
 --> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/option.rs:572:1
 ::: /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/option.rs:576:5
  |
  = note: 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, потому что последний образец совпадёт со всеми значениями, которые не указаны явно. Этот универсальный образец удовлетворяет требованию, что match должен быть исчерпывающим. Обратите внимание, что мы должны поместить универсальную ветвь последней, потому что образцы оцениваются по порядку. Если мы поместим универсальную ветвь раньше, другие ветви никогда не выполнятся, поэтому 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, что не собираемся использовать никакое другое значение, которое не совпадает с образцом в более ранней ветви, и не хотим выполнять никакой код в этом случае.

Есть больше об образцах и сопоставлении, что мы рассмотрим в Главе 19.

Как соответствия взаимодействуют с владением

Если перечисление содержит некопируемые данные, такие как String, то следует быть осторожным с тем, перемещает или заимствует match эти данные. Например, эта программа с использованием Option<String> скомпилируется:

Но если мы заменим заполнитель в Some(_) на имя переменной, например Some(s), то программа НЕ скомпилируется:

opt — это обычное перечисление — его тип Option<String>, а не ссылка, такая как &Option<String>. Поэтому match по opt переместит неигнорируемые поля, такие как s. Обратите внимание, как opt теряет разрешения на чтение и владение раньше во второй программе по сравнению с первой. После выражения match данные внутри opt были перемещены, поэтому чтение opt в println недопустимо.

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

Rust «протолкнёт» ссылку из внешнего перечисления, &Option<String>, во внутреннее поле, &String. Поэтому s имеет тип &String, и opt можно использовать после match. Чтобы лучше понять механизм «проталкивания», см. раздел о режимах привязки в Справочнике Rust.

Краткий контроль потока выполнения с if let и let else

Синтаксис 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}"),
        _ => (),
    }
}
Listing 6-6: match, который выполняет код только когда значение равно Some

Если значение равно 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, где выражение передаётся в 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;
    }
}

Оставаться на «счастливом пути» с let...else

Общая закономерность — выполнить некоторые вычисления, когда значение присутствует, и вернуть значение по умолчанию в противном случае. Продолжая наш пример с монетами, имеющими значение UsState, если бы мы хотели сказать что-то забавное в зависимости от того, как давно существовал штат на четверти, мы могли бы добавить метод в UsState для проверки возраста штата, вот так:

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    if let Coin::Quarter(state) = coin {
        if state.existed_in(1900) {
            Some(format!("{state:?} is pretty old, for America!"))
        } else {
            Some(format!("{state:?} is relatively new."))
        }
    } else {
        None
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}

Затем мы могли бы использовать if let для сопоставления с типом монеты, вводя переменную state в теле условия, как в Листинге 6-7.

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    if let Coin::Quarter(state) = coin {
        if state.existed_in(1900) {
            Some(format!("{state:?} is pretty old, for America!"))
        } else {
            Some(format!("{state:?} is relatively new."))
        }
    } else {
        None
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}
Listing 6-7: Проверка существования штата в 1900 году с использованием вложенных условных операторов внутри if let.

Это решает задачу, но переносит работу в тело оператора if let, и если работа, которую нужно выполнить, более сложная, может быть трудно понять, как именно связаны верхние ветви. Мы также могли бы воспользоваться тем, что выражения производят значение, либо чтобы получить state из if let, либо чтобы вернуться досрочно, как в Листинге 6-8. (Вы могли бы сделать подобное и с match.)

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    let state = if let Coin::Quarter(state) = coin {
        state
    } else {
        return None;
    };

    if state.existed_in(1900) {
        Some(format!("{state:?} is pretty old, for America!"))
    } else {
        Some(format!("{state:?} is relatively new."))
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}
Listing 6-8: Использование if let для получения значения или досрочного возврата.

Но и это не очень удобно для понимания! Одна ветвь if let производит значение, а другая полностью возвращается из функции.

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

В Листинге 6-9 вы можете увидеть, как выглядит Листинг 6-8 при использовании let...else вместо if let.

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    let Coin::Quarter(state) = coin else {
        return None;
    };

    if state.existed_in(1900) {
        Some(format!("{state:?} is pretty old, for America!"))
    } else {
        Some(format!("{state:?} is relatively new."))
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}
Listing 6-9: Использование let...else для прояснения потока выполнения через функцию.

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

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

Резюме

Мы теперь рассмотрели, как использовать перечисления для создания пользовательских типов, которые могут быть одним из набора перечисленных значений. Мы показали, как тип Option<T> из стандартной библиотеки помогает использовать систему типов для предотвращения ошибок. Когда значения перечисления содержат данные внутри, вы можете использовать match или if let для извлечения и использования этих значений, в зависимости от того, сколько случаев нужно обработать.

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

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

Инвентаризация владения #1

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

Новая технология: веб-среда разработки

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

  • Наведите курсор мыши на replace, чтобы увидеть его тип и описание.
  • Наведите курсор мыши на s2, чтобы увидеть его выведенный тип.


/// Превращает строку в гораздо более захватывающую строку
fn make_exciting(s: &str) -> String {
  let s2 = s.replace(".", "!");
  let s3 = s2.replace("?", "‽");
  s3
}


Несколько важных оговорок об этой экспериментальной технологии:

СОВМЕСТИМОСТЬ С ПЛАТФОРМАМИ: веб-среда разработки не работает на сенсорных экранах. Она была протестирована только в Google Chrome 109 и Firefox 107. Она может не работать в более старых версиях Safari.

ИСПОЛЬЗОВАНИЕ ПАМЯТИ: веб-среда разработки использует сборку WebAssembly rust-analyzer, которая может занимать значительный объём памяти. Каждый экземпляр среды, по-видимому, занимает около ~300 МБ. (Примечание: мы также получили некоторые сообщения об использовании >10 ГБ памяти.)

ПРОКРУТКА: веб-среда разработки «съест» ваш курсор, если он пересекается с редактором при прокрутке. Если у вас возникают проблемы с прокруткой страницы, попробуйте переместить курсор на самую правую полосу прокрутки.

ВРЕМЯ ЗАГРУЗКИ: среда может занимать до 15 секунд для инициализации новой программы. Она будет отображать «Загрузка…», пока вы взаимодействуете с кодом в редакторе.

Вопросы

Управление растущими проектами с помощью пакетов, крейтов и модулей

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

Все программы, которые мы писали до сих пор, находились в одном модуле в одном файле. По мере роста проекта следует организовать код, разделив его на несколько модулей, а затем и на несколько файлов. Пакет может содержать несколько бинарных крейтов и, опционально, один библиотечный крейт. По мере роста пакета вы можете выделить части в отдельные крейты, которые станут внешними зависимостями. В этой главе рассматриваются все эти техники. Для очень больших проектов, состоящих из набора взаимосвязанных пакетов, развивающихся вместе, Cargo предоставляет рабочие пространства (workspaces), которые мы рассмотрим в главе 14 в разделе “Cargo Workspaces”.

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

Смежным понятием является область видимости (scope): вложенный контекст, в котором написан код, имеет набор имён, определённых как “находящиеся в области видимости”. При чтении, написании и компиляции кода программистам и компиляторам нужно знать, относится ли конкретное имя в конкретном месте к переменной, функции, структуре, перечислению, модулю, константе или другому элементу и что означает этот элемент. Вы можете создавать области видимости и изменять, какие имена находятся внутри или вне них. В одной области видимости нельзя иметь два элемента с одинаковым именем; существуют инструменты для разрешения конфликтов имён.

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

  • Пакеты: Возможность Cargo, позволяющая собирать, тестировать и делиться крейтами
  • Крейты: Дерево модулей, которое производит библиотеку или исполняемый файл
  • Модули и use: Позволяют управлять организацией, областью видимости и приватностью путей
  • Пути: Способ именования элемента, такого как структура, функция или модуль

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

Пакеты и крейты

Первые элементы системы модулей, которые мы рассмотрим, — это пакеты и крейты.

Крейт — это минимальный объём кода, который компилятор Rust рассматривает за один раз. Даже если вы запускаете rustc вместо cargo и передаёте один файл с исходным кодом (как мы делали в разделе «Написание и запуск программы на Rust» в главе 1), компилятор считает этот файл крейтом. Крейты могут содержать модули, и модули могут быть определены в других файлах, которые компилируются вместе с крейтом, как мы увидим в следующих разделах.

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

Библиотечные крейты не имеют функции main и не компилируются в исполняемый файл. Вместо этого они определяют функциональность, предназначенную для использования в нескольких проектах. Например, крейт rand, который мы использовали в главе 2, предоставляет функциональность для генерации случайных чисел. Чаще всего, когда разработчики Rust говорят «крейт», они имеют в виду библиотечный крейт и используют слово «крейт» как синоним общего понятия «библиотека» в программировании.

Корень крейта — это исходный файл, с которого начинает работу компилятор Rust и который составляет корневой модуль вашего крейта (мы подробно объясним модули в разделе «Определение модулей для управления областью видимости и доступом»)modules).

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

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

Давайте разберём, что происходит при создании пакета. Сначала мы выполняем команду cargo new my-project:

$ cargo new my-project
     Created binary (application) `my-project` package
$ ls my-project
Cargo.toml
src
$ ls my-project/src
main.rs

После выполнения cargo new my-project мы используем 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: каждый файл будет отдельным бинарным крейтом.

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

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

Шпаргалка по модулям

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

  • Начинайте с корня крейта: При компиляции крейта компилятор сначала ищет код для компиляции в корневом файле крейта (обычно src/lib.rs для библиотечного крейта или src/main.rs для бинарного крейта).
  • Объявление модулей: В корневом файле крейта вы можете объявлять новые модуules; допустим, вы объявляете модуль “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 в модуле garden vegetables будет находиться по пути crate::garden::vegetables::Asparagus.
  • Приватное vs публичное: Код внутри модуля по умолчанию приватный для его родительских модулей. Чтобы сделать модуль публичным, объявите его с помощью 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, и он содержит:

Filename: 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, а это:

Filename: src/garden.rs
pub mod vegetables;

Здесь pub mod vegetables; означает, что код в src/garden/vegetables.rs также включается. Этот код:

#[derive(Debug)]
pub struct Asparagus {}

Теперь давайте подробно разберём эти правила и продемонстрируем их в действии!

Группировка связанного кода в модулях

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

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

В индустрии общественного питания некоторые части ресторана называются передней частью зала (front of house), а другие — задней частью зала (back of house). Передняя часть зала — это место, где находятся клиенты; это включает в себя, где хостессы рассаживают клиентов, официанты принимают заказы и оплату, а бармены готовят напитки. Задняя часть зала — это место, где повара и кухонные работники работают на кухне, мойщики убирают, а менеджеры занимаются административной работой.

Чтобы структурировать наш крейт таким образом, мы можем организовать его функции во вложенные модули. Создайте новую библиотеку с именем restaurant, выполнив cargo new restaurant --lib. Затем введите код из Листинга 7-1 в src/lib.rs, чтобы определить некоторые модули и сигнатуры функций; этот код представляет раздел передней части зала.

Filename: 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() {}
    }
}
Listing 7-1: Модуль front_of_house, содержащий другие модули, которые, в свою очередь, содержат функции

Мы определяем модуль с помощью ключевого слова mod, за которым следует имя модуля (в этом случае front_of_house). Тело модуля затем помещается внутрь фигурных скобок. Внутри модулей мы можем размещать другие модули, как в этом случае с модулями hosting и serving. Модули также могут содержать определения других элементов, таких как структуры, перечисления, константы, типажи и, как в Листинге 7-1, функции.

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

Ранее мы упоминали, что src/main.rs и src/lib.rs называются корнями крейта. Причина их названия в том, что содержимое любого из этих двух файлов образует модуль с именем crate в корне структуры модулей крейта, известной как дерево модулей.

Листинг 7-2 показывает дерево модулей для структуры в Листинге 7-1.

crate
 └── front_of_house
     ├── hosting
     │   ├── add_to_waitlist
     │   └── seat_at_table
     └── serving
         ├── take_order
         ├── serve_order
         └── take_payment
Listing 7-2: Дерево модулей для кода в Листинге 7-1

Это дерево показывает, как некоторые модули вложены в другие модули; например, hosting вложен в front_of_house. Дерево также показывает, что некоторые модули являются братьями и сёстрами, то есть они определены в одном и том же модуле; hosting и serving — братья и сёстры, определённые внутри front_of_house. Если модуль A содержится внутри модуля B, мы говорим, что модуль A является потомком модуля B, а модуль B — родителем модуля 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.

Filename: 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();
}
Listing 7-3: Вызов функции 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
Listing 7-4: Ошибки компилятора при сборке кода из Листинга 7-3

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

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

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

Раскрытие путей с помощью ключевого слова pub

Вернёмся к ошибке в Листинге 7-4, которая сообщила нам, что модуль hosting приватный. Мы хотим, чтобы функция eat_at_restaurant в родительском модуле имела доступ к функции add_to_waitlist в дочернем модуле, поэтому мы отмечаем модуль hosting ключевым словом pub, как показано в Листинге 7-5.

Filename: src/lib.rs
mod front_of_house {
    pub mod hosting {
        fn add_to_waitlist() {}
    }
}

// -- snip --
pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}
Listing 7-5: Объявление модуля hosting как pub для использования его из eat_at_restaurant

К сожалению, код в Листинге 7-5 по-прежнему приводит к ошибкам компилятора, как показано в Листинге 7-6.

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: function `add_to_waitlist` is private
  --> src/lib.rs:10:37
   |
10 |     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:13:30
   |
13 |     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
Listing 7-6: Ошибки компилятора при сборке кода из Листинга 7-5

Что произошло? Добавление ключевого слова pub перед mod hosting делает модуль публичным. С этим изменением, если мы можем получить доступ к front_of_house, мы можем получить доступ к hosting. Но содержимое hosting по-прежнему приватно; простое сделание модуля публичным не делает его содержимое публичным. Ключевое слово pub на модуле только позволяет коду в его модулях-предках ссылаться на него, но не даёт доступа к его внутреннему коду. Поскольку модули — это контейнеры, мы не можем сделать много, только сделав модуль публичным; нам нужно пойти дальше и выбрать, сделать ли один или несколько элементов внутри модуля публичными.

Ошибки в Листинге 7-6 говорят, что функция add_to_waitlist приватная. Правила конфиденциальности применяются к структурам, перечислениям, функциям и методам, а также к модулям.

Давайте также сделаем функцию add_to_waitlist публичной, добавив ключевое слово pub перед её определением, как в Листинге 7-7.

Filename: src/lib.rs
mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

// -- snip --
pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}
Listing 7-7: Добавление ключевого слова pub к mod hosting и fn add_to_waitlist позволяет нам вызвать функцию из eat_at_restaurant

Теперь код скомпилируется! Чтобы понять, почему добавление ключевого слова pub позволяет нам использовать эти пути в eat_at_restaurant с точки зрения правил конфиденциальности, давайте посмотрим на абсолютный и относительный пути.

В абсолютном пути мы начинаем с crate — корня дерева модулей нашего крейта. Модуль front_of_house определён в корне крейта. Хотя front_of_house не является публичным, потому что функция eat_at_restaurant определена в том же модуле, что и front_of_house (то есть eat_at_restaurant и front_of_house являются sibling-ами), мы можем ссылаться на 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, чтобы упростить зависимость от вашего крейта. Эти соображения выходят за рамки этой книги; если вас интересует эта тема, см. Руководство по Rust API.

Рекомендации для пакетов с бинарным крейтом и библиотекой

Мы упомянули, что пакет может содержать как корень бинарного крейта src/main.rs, так и корень библиотечного крейта src/lib.rs, и оба крейта по умолчанию будут иметь имя пакета. Обычно пакеты с таким шаблоном, содержащие и библиотеку, и бинарный крейт, будут иметь только достаточно кода в бинарном крейте для запуска исполняемого файла, который вызывает код, определённый в библиотечном крейте. Это позволяет другим проектам использовать максимальную функциональность, которую предоставляет пакет, потому что код библиотечного крейта может быть общим.

Дерево модулей должно быть определено в src/lib.rs. Затем любые публичные элементы могут использоваться в бинарном крейте, начиная пути с имени пакета. Бинарный крейт становится пользователем библиотечного крейта, как и полностью внешний крейт использовал бы библиотечный крейт: он может использовать только публичный API. Это помогает вам разработать хороший API; вы не только автор, но и клиент!

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

Начало относительных путей с super

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

Рассмотрим код в Листинге 7-8, который моделирует ситуацию, когда повар исправляет неправильный заказ и лично выносит его клиенту. Функция fix_incorrect_order, определённая в модуле back_of_house, вызывает функцию deliver_order, определённую в родительском модуле, указывая путь к deliver_order, начиная с super.

Filename: src/lib.rs
fn deliver_order() {}

mod back_of_house {
    fn fix_incorrect_order() {
        cook_order();
        super::deliver_order();
    }

    fn cook_order() {}
}
Listing 7-8: Вызов функции с использованием относительного пути, начинающегося с super

Функция 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. Это моделирует ситуацию в ресторане, где клиент может выбрать тип хлеба, который идёт с едой, но повар решает, какой фрукт сопровождает еду, исходя из сезона и наличия. Доступные фрукты быстро меняются, поэтому клиенты не могут выбрать фрукт или даже увидеть, какой фрукт они получат.

Filename: 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");
}
Listing 7-9: Структура с некоторыми публичными полями и некоторыми приватными полями

Поскольку поле toast в структуре back_of_house::Breakfast является публичным, в eat_at_restaurant мы можем записывать и читать поле toast, используя точечную нотацию. Обратите внимание, что мы не можем использовать поле seasonal_fruit в eat_at_restaurant, потому что seasonal_fruit приватно. Попробуйте раскомментировать строку, изменяющую значение поля seasonal_fruit, чтобы увидеть, какую ошибку вы получите!

Также обратите внимание, что поскольку back_of_house::Breakfast имеет приватное поле, структуре требуется предоставить публичную связанную функцию, которая создаёт экземпляр Breakfast (мы назвали её summer здесь). Если бы у Breakfast не было такой функции, мы не смогли бы создать экземпляр Breakfast в eat_at_restaurant, потому что не смогли бы установить значение приватного поля seasonal_fruit в eat_at_restaurant.

В отличие от этого, если мы делаем перечисление публичным, все его варианты тогда становятся публичными. Нам нужно только pub перед ключевым словом enum, как показано в Листинге 7-10.

Filename: 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;
}
Listing 7-10: Обозначение перечисления как публичного делает все его варианты публичными.

Поскольку мы сделали перечисление Appetizer публичным, мы можем использовать варианты Soup и Salad в eat_at_restaurant.

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

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

Доступ к путям с помощью ключевого слова use

Приходится каждый раз писать полные пути для вызова функций, что неудобно и повторяется. В Листинге 7-7, независимо от того, выбирали ли мы абсолютный или относительный путь к функции add_to_waitlist, каждый раз при её вызове нам также требовалось указывать front_of_house и hosting. К счастью, есть способ упростить этот процесс: мы можем один раз создать ярлык для пути с помощью ключевого слова use, а затем использовать это короткое имя везде в области видимости.

В Листинге 7-11 мы делаем модуль crate::front_of_house::hosting доступным в области видимости функции eat_at_restaurant, поэтому для вызова функции add_to_waitlist в eat_at_restaurant нам нужно указать только hosting::add_to_waitlist.

Filename: 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();
}
Listing 7-11: Доступ к модулю с помощью use

Добавление use и пути в область видимости похоже на создание символической ссылки в файловой системе. Добавив use crate::front_of_house::hosting в корень крейта, hosting становится допустимым именем в этой области видимости, так как будто бы модуль hosting был определён в корне крейта. Пути, сделанные доступными с помощью use, также проверяют правила приватности, как и любые другие пути.

Обратите внимание, что use создаёт ярлык только для конкретной области видимости, в которой находится это use. Листинг 7-12 перемещает функцию eat_at_restaurant в новый дочерний модуль с именем customer, который уже является другой областью видимости, отличной от области видимости оператора use, поэтому тело функции не скомпилируется.

Filename: 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();
    }
}
Listing 7-12: Оператор use действует только в области видимости, где он находится.

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

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0433]: failed to resolve: use of undeclared crate or module `hosting`
  --> src/lib.rs:11:9
   |
11 |         hosting::add_to_waitlist();
   |         ^^^^^^^ use of undeclared crate or module `hosting`
   |
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.

Filename: 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();
}
Listing 7-13: Доступ к функции add_to_waitlist с помощью use, что неидиоматично

Хотя и Листинг 7-11, и Листинг 7-13 выполняют одну и ту же задачу, Листинг 7-11 — это идиоматический способ сделать функцию доступной с помощью use. Доступ к родительскому модулю функции с помощью use означает, что при вызове функции нам нужно указать родительский модуль. Указание родительского модуля при вызове функции делает ясным, что функция не определена локально, при этом минимизируя повторение полного пути. Код в Листинге 7-13 неясен в том, где определён add_to_waitlist.

С другой стороны, при доступе к структурам, перечислениям и другим элементам с помощью use идиоматично указывать полный путь. Листинг 7-14 показывает идиоматический способ сделать структуру HashMap из стандартной библиотеки доступной в области видимости бинарного крейта.

Filename: src/main.rs
use std::collections::HashMap;

fn main() {
    let mut map = HashMap::new();
    map.insert(1, 2);
}
Listing 7-14: Идиоматический доступ к HashMap

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

Исключением из этого идиома является ситуация, когда мы делаем доступными два элемента с одинаковыми именами с помощью операторов use, потому что Rust не позволяет этого. Листинг 7-15 показывает, как сделать доступными два типа Result с одинаковыми именами, но из разных родительских модулей, и как на них ссылаться.

Filename: src/lib.rs
use std::fmt;
use std::io;

fn function1() -> fmt::Result {
    // --snip--
    Ok(())
}

fn function2() -> io::Result<()> {
    // --snip--
    Ok(())
}
Listing 7-15: Доступ к двум типам с одинаковыми именами в одну область видимости требует использования их родительских модулей.

Как видите, использование родительских модулей различает два типа Result. Если вместо этого мы укажем use std::fmt::Result и use std::io::Result, у нас будет два типа Result в одной области видимости, и Rust не будет знать, какой из них мы имеем в виду, когда используем Result.

Предоставление новых имён с помощью ключевого слова as

Есть другое решение проблемы доступа к двум типам с одинаковыми именами в одну область видимости с помощью use: после пути мы можем указать as и новое локальное имя, или псевдоним, для типа. Листинг 7-16 показывает ещё один способ написать код из Листинга 7-15, переименовав один из двух типов Result с помощью as.

Filename: src/lib.rs
use std::fmt::Result;
use std::io::Result as IoResult;

fn function1() -> Result {
    // --snip--
    Ok(())
}

fn function2() -> IoResult<()> {
    // --snip--
    Ok(())
}
Listing 7-16: Переименование типа при его доступе с помощью ключевого слова as

Во втором операторе use мы выбрали новое имя IoResult для типа std::io::Result, которое не будет конфликтовать с Result из std::fmt, который мы также сделали доступным. Листинг 7-15 и Листинг 7-16 считаются идиоматичными, поэтому выбор за вами!

Реэкспорт имён с помощью pub use

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

Листинг 7-17 показывает код из Листинга 7-11 с изменённым в корневом модуле use на pub use.

Filename: 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();
}
Listing 7-17: Предоставление имени доступным для любого кода из новой области видимости с помощью pub use

До этого изменения внешний код должен был вызывать функцию add_to_waitlist, используя путь restaurant::front_of_house::hosting::add_to_waitlist(), что также требовало, чтобы модуль front_of_house был помечен как pub. Теперь, когда этот pub use реэкспортировал модуль hosting из корневого модуля, внешний код может использовать путь restaurant::hosting::add_to_waitlist() вместо этого.

Реэкспорт полезен, когда внутренняя структура вашего кода отличается от того, как программисты, вызывающие ваш код, думают о предметной области. Например, в этой метафоре ресторана владельцы ресторана думают о «передней части дома» и «задней части дома». Но посетители ресторана, вероятно, не будут думать о частях ресторана в этих терминах. С помощью pub use мы можем писать наш код с одной структурой, но предоставлять другую структуру. Это делает нашу библиотеку хорошо организованной как для программистов, работающих над библиотекой, так и для программистов, вызывающих библиотеку. Мы рассмотрим ещё один пример pub use и то, как он влияет на документацию вашего крейта, в разделе «Экспорт удобного публичного API с помощью pub use» в Главе 14.

Использование внешних пакетов

В Главе 2 мы написали проект игры-угадывания, который использовал внешний пакет под названием rand для получения случайных чисел. Чтобы использовать rand в нашем проекте, мы добавили эту строку в файл Cargo.toml:

Filename: 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 доступными:

Filename: 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.

Filename: 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!"),
    }
}
Listing 7-18: Указание вложенного пути для доступа к нескольким элементам с общим префиксом

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

Мы можем использовать вложенный путь на любом уровне в пути, что полезно при объединении двух операторов use, которые разделяют подпуть. Например, Листинг 7-19 показывает два оператора use: один делает доступным std::io, а другой — std::io::Write.

Filename: src/lib.rs
use std::io;
use std::io::Write;
Listing 7-19: Два оператора use, где один является подпутем другого

Общая часть этих двух путей — std::io, и это полный первый путь. Чтобы объединить эти два пути в один оператор use, мы можем использовать self во вложенном пути, как показано в Листинге 7-20.

Filename: src/lib.rs
use std::io::{self, Write};
Listing 7-20: Объединение путей из Листинга 7-19 в один оператор use

Эта строка делает доступными std::io и std::io::Write.

Глобальный оператор

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

#![allow(unused)]
fn main() {
use std::collections::*;
}

Этот оператор use делает все публичные элементы, определённые в std::collections, доступными в текущей области видимости. Будьте осторожны при использовании глобального оператора! Глобальный оператор может затруднить определение того, какие имена находятся в области видимости и где было определено используемое в вашей программе имя. Кроме того, если зависимость изменит свои определения, то то, что вы импортировали, также изменится, что может привести к ошибкам компиляции при обновлении зависимости, если зависимость добавит определение с тем же именем, что и ваше определение в той же области видимости, например.

Глобальный оператор часто используется при тестировании, чтобы сделать всё под тестом доступным в модуле tests; мы поговорим об этом в разделе «Как писать тесты» в Главе 11. Глобальный оператор также иногда используется как часть шаблона прелюдии: см. документацию стандартной библиотеки для получения дополнительной информации об этом шаблоне.

Разделение модулей на разные файлы

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

Например, начнём с кода из листинга 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.

Filename: src/lib.rs
mod front_of_house;

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}
Listing 7-21: Объявление модуля front_of_house, тело которого будет в src/front_of_house.rs

Далее поместите код, который был в фигурных скобках, в новый файл с именем src/front_of_house.rs, как показано в листинге 7-22. Компилятор знает, что нужно искать в этом файле, потому что он наткнулся на объявление модуля в корне крейта с именем front_of_house.

Filename: src/front_of_house.rs
pub mod hosting {
    pub fn add_to_waitlist() {}
}
Listing 7-22: Определения внутри модуля front_of_house в src/front_of_house.rs

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

Далее мы извлечём модуль hosting в собственный файл. Процесс немного отличается, потому что hosting является дочерним модулем front_of_house, а не корневого модуля. Мы поместим файл для hosting в новый каталог, который будет назван в честь его предков в дереве модулей, в данном случае src/front_of_house.

Чтобы начать перемещение hosting, изменим src/front_of_house.rs так, чтобы он содержал только объявление модуля hosting:

Filename: src/front_of_house.rs
pub mod hosting;

Затем мы создаём каталог src/front_of_house и файл hosting.rs для содержания определений, сделанных в модуле hosting:

Filename: 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.

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

Распространённые коллекции

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

  • Вектор позволяет хранить переменное количество значений, расположенных последовательно.
  • Строка — это коллекция символов. Мы уже упоминали тип String ранее, но в этой главе поговорим о нём подробно.
  • Хэш-таблица позволяет связать значение с конкретным ключом. Это частная реализация более общей структуры данных, называемой отображением.

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

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

Хранение списков значений с помощью векторов

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

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

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

fn main() {
    let v: Vec<i32> = Vec::new();
}
Listing 8-1: Создание нового пустого вектора для хранения значений типа i32

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

Чаще вы будете создавать Vec<T> с начальными значениями, и Rust выведет тип значения, которое вы хотите хранить, поэтому вам редко нужно делать такую аннотацию типа. Rust удобно предоставляет макрос vec!, который создаст новый вектор, содержащий переданные ему значения. Листинг 8-2 создаёт новый Vec<i32>, содержащий значения 1, 2 и 3. Тип целого числа — i32, потому что это тип целого числа по умолчанию, как мы обсуждали в разделе “Типы данных” Главы 3.

fn main() {
    let v = vec![1, 2, 3];
}
Listing 8-2: Создание нового вектора, содержащего значения

Поскольку мы предоставили начальные значения i32, Rust может вывести, что тип vVec<i32>, и аннотация типа не требуется. Далее мы посмотрим, как изменять вектор.

Изменение вектора

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

fn main() {
    let mut v = Vec::new();

    v.push(5);
    v.push(6);
    v.push(7);
    v.push(8);
}
Listing 8-3: Использование метода push для добавления значений в вектор

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

Чтение элементов векторов

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

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

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

    let third: &i32 = &v[2];
    println!("The third element is {third}");

    let third: Option<&i32> = v.get(2);
    match third {
        Some(third) => println!("The third element is {third}"),
        None => println!("There is no third element."),
    }
}
Listing 8-4: Использование синтаксиса индексации и метода get для доступа к элементу вектора

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

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

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

    let does_not_exist = &v[100];
    let does_not_exist = v.get(100);
}
Listing 8-5: Попытка доступа к элементу с индексом 100 в векторе, содержащем пять элементов

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

Когда методу get передаётся индекс вне вектора, он возвращает None без паники. Вы бы использовали этот метод, если доступ к элементу за пределами вектора может иногда происходить при нормальных обстоятельствах. Затем ваш код будет содержать логику для обработки либо Some(&element), либо None, как обсуждалось в Главе 6. Например, индекс может поступать от человека, вводящего число. Если он случайно вводит слишком большое число и программа получает значение None, вы можете сообщить пользователю, сколько элементов в текущем векторе, и дать ему ещё один шанс ввести допустимое значение. Это будет более дружелюбно к пользователю, чем падение программы из-за опечатки!

Когда программа имеет действительную ссылку, проверка заимствований обеспечивает соблюдение правил владения и заимствования (рассмотренных в Главе 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}");
}
Listing 8-6: Попытка добавить элемент в вектор, удерживая ссылку на элемент

Компиляция этого кода приведёт к следующей ошибке:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:5
  |
4 |     let first = &v[0];
  |                  - immutable borrow occurs here
5 |
6 |     v.push(6);
  |     ^^^^^^^^^ mutable borrow occurs here
7 |
8 |     println!("The first element is: {first}");
  |                                     ------- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `collections` (bin "collections") due to 1 previous error

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

Примечание: Для получения дополнительной информации о деталях реализации типа Vec<T> см. “The Rustonomicon”.

Итерация по значениям в векторе

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

fn main() {
    let v = vec![100, 32, 57];
    for i in &v {
        println!("{i}");
    }
}
Listing 8-7: Печать каждого элемента в векторе путём итерации по элементам с использованием цикла for

Чтобы прочитать число, на которое указывает n_ref, мы должны использовать оператор разыменования *, чтобы получить значение в n_ref, прежде чем мы сможем прибавить 1 к нему, как рассматривалось в “Разыменование указателя даёт доступ к его данным”.

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

fn main() {
    let mut v = vec![100, 32, 57];
    for i in &mut v {
        *i += 50;
    }
}
Listing 8-8: Итерация по изменяемым ссылкам на элементы в векторе

Чтобы изменить значение, на которое указывает изменяемая ссылка, мы снова используем оператор разыменования *, чтобы получить значение в n_ref, прежде чем сможем использовать оператор +=.

Безопасное использование итераторов

Мы обсудим подробнее, как работают итераторы, в Главе 13.2 “Обработка серии элементов с помощью итераторов”. Пока что одна важная деталь: итераторы содержат указатель на данные внутри вектора. Мы можем увидеть, как работают итераторы, раскрыв цикл for в соответствующие вызовы методов Vec::iter и Iterator::next:

Заметьте, что итератор iter — это указатель, который перемещается через каждый элемент вектора. Метод next продвигает итератор и возвращает необязательную ссылку на предыдущий элемент, либо Some (которую мы раскрываем), либо None в конце вектора.

Эта деталь важна для безопасного использования векторов. Например, предположим, мы хотим продублировать вектор на месте, так что [1, 2] станет [1, 2, 1, 2]. Наивная реализация может выглядеть так, с аннотациями разрешений, выведенных компилятором:

Заметьте, что v.iter() удаляет разрешение W из *v. Поэтому операция v.push(..) не имеет ожидаемого разрешения W. Компилятор Rust отклонит эту программу с соответствующим сообщением об ошибке:

error[E0502]: cannot borrow `*v` as mutable because it is also borrowed as immutable
 --> test.rs:3:9
  |
2 |     for n_ref in v.iter() {
  |                  --------
  |                  |
  |                  immutable borrow occurs here
  |                  immutable borrow later used here
3 |         v.push(*n_ref);
  |         ^^^^^^^^^^^^^^ mutable borrow occurs here

Как мы обсуждали в Главе 4, проблема безопасности, лежащая в основе этой ошибки, — это чтение освобождённой памяти. Как только происходит v.push(1), вектор перераспределит своё содержимое и сделает указатель итератора недействительным. Поэтому для безопасного использования итераторов Rust не позволяет добавлять или удалять элементы из вектора во время итерации.

Один способ итерировать по вектору без использования указателя — использовать диапазон, как мы использовали для срезов строк в Главе 4.4. Например, диапазон 0 .. v.len() — это итератор по всем индексам вектора v, как видно здесь:

Использование перечисления для хранения нескольких типов

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

Например, предположим, мы хотим получить значения из строки в электронной таблице, в которой некоторые столбцы в строке содержат целые числа, некоторые числа с плавающей точкой, а некоторые строки. Мы можем определить перечисление, варианты которого будут хранить разные типы значений, и все варианты перечисления будут считаться одним типом: типом перечисления. Затем мы можем создать вектор, который будет хранить это перечисление, а значит, в конечном итоге, хранить разные типы. Мы продемонстрировали это в Листинге 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),
    ];
}
Listing 8-9: Определение enum для хранения значений разных типов в одном векторе

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

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

Теперь, когда мы обсудили некоторые из наиболее распространённых способов использования векторов, обязательно ознакомьтесь с документацией 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
}
Listing 8-10: Показ места, где вектор и его элементы удаляются

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

Перейдём к следующему типу коллекции: String!

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

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

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

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

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

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

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

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

fn main() {
    let mut s = String::new();
}
Listing 8-11: Создание новой пустой String

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

fn main() {
    let data = "initial contents";

    let s = data.to_string();

    // The method also works on a literal directly:
    let s = "initial contents".to_string();
}
Listing 8-12: Использование метода to_string для создания String из строкового литерала

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

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

fn main() {
    let s = String::from("initial contents");
}
Listing 8-13: Использование функции String::from для создания String из строкового литерала

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

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

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}
Listing 8-14: Хранение приветствий на разных языках в строках

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

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

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

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

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

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}
Listing 8-15: Добавление строкового среза к String с помощью метода push_str

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

fn main() {
    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 is {s2}");
}
Listing 8-16: Использование строкового среза после добавления его содержимого к String

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

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

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}
Listing 8-17: Добавление одного символа к значению String с помощью push

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

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

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

fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // note s1 has been moved here and can no longer be used
}
Listing 8-18: Использование оператора + для объединения двух значений String в новое значение String

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

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

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

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

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

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

  1. add принимает владение s1,
  2. добавляет копию содержимого s2 к s1,
  3. а затем возвращает обратно владение s1.

Если у s1 достаточно ёмкости для s2, то выделения памяти не происходит. Однако, если у s1 недостаточно ёмкости для s2, то s1 внутренне сделает большее выделение памяти, чтобы вместить обе строки.

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

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("hi");
    let h = s1[0];
}
Listing 8-19: Попытка использовать синтаксис индексации с String

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

$ 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`
  |
  = 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<str>` is not implemented for `{integer}`
          but 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 байта. Каждая из этих букв занимает один байт при кодировке в UTF-8. Однако следующая строка может вас удивить (обратите внимание, что эта строка начинается с заглавной кириллической буквы Ze, а не с цифры 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. Пользователи обычно не хотят, чтобы возвращалось байтовое значение, даже если строка содержит только латинские буквы: если &"hi"[0] был бы допустимым кодом, возвращающим байтовое значение, он вернул бы 104, а не h.

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

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

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

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

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

Это 18 байтов, и именно так компьютеры в конечном итоге хранят эти данные. Если мы посмотрим на них как на значения Unicode-скаляра, которыми является тип char в Rust, эти байты будут выглядеть так:

['न', 'म', 'स', '्', 'त', 'े']

Здесь шесть значений char, но четвёртое и шестое — не буквы: это диакритические знаки, которые не имеют смысла сами по себе. Наконец, если мы посмотрим на них как на графемные кластеры, мы получим то, что человек назвал бы четырьмя буквами, из которых состоит хинди слово:

["न", "म", "स्", "ते"]

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

Ещё одна причина, по которой Rust не позволяет нам индексировать String для получения символа, в том, что операции индексации, как ожидается, всегда занимают постоянное время (O(1)). Но невозможно гарантировать такую производительность с String, потому что Rust должен был бы пройти по содержимому от начала до индекса, чтобы определить, сколько там допустимых символов.

Срезы строк

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

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

#![allow(unused)]
fn main() {
let hello = "Здравствуйте";

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

Здесь s будет &str, содержащим первые четыре байта строки. Ранее мы упоминали, что каждый из этих символов занимает два байта, что означает, что 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

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

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

Лучший способ работать с частями строк — явно указать, хотите ли вы символы или байты. Для отдельных значений Unicode-скаляра используйте метод 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<K, V> хранит сопоставление ключей типа K значениям типа V, используя хэш-функцию, которая определяет, как эти ключи и значения размещаются в памяти. Многие языки программирования поддерживают подобную структуру данных, но часто используют другие названия, такие как hash, map, object, hash table, dictionary или associative array.

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

В этом разделе мы рассмотрим базовый 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);
}
Listing 8-20: Создание новой хэш-мапы и вставка некоторых ключей и значений

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

Как и вектора, хэш-мапы хранят свои данные в куче. Эта HashMap имеет ключи типа String и значения типа i32. Как и вектора, хэш-мапы однородны: все ключи должны иметь одинаковый тип, и все значения должны иметь одинаковый тип.

Доступ к значениям в хэш-мапе

Мы можем получить значение из хэш-мапы, передав её ключ методу 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);
}
Listing 8-21: Получение счёта команды Синие, хранящегося в хэш-мапе

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

Мы можем перебирать каждую пару ключ-значение в хэш-мапе аналогично тому, как мы это делаем с векторами, используя цикл 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, значения копируются в хэш-мапу. Для владеющих значений, таких как 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!
}
Listing 8-22: Демонстрация того, что ключи и значения становятся владением хэш-мапы после их вставки

Мы не можем использовать переменные field_name и field_value после того, как они были перемещены в хэш-мапу с помощью вызова insert.

Если мы вставляем в хэш-мапу ссылки на значения, значения не будут перемещены в хэш-мапу. Значения, на которые указывают ссылки, должны быть действительными как минимум столько же, сколько и хэш-мапа. Мы подробнее обсудим эти вопросы в разделе «Проверка ссылок с помощью времени жизни» в Главе 10.

Обновление хэш-мапы

Хотя количество пар ключ-значение может увеличиваться, каждый уникальный ключ может иметь только одно значение одновременно (но не наоборот: например, и команда Синие, и команда Жёлтые могут иметь значение 10 в хэш-мапе scores).

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

Перезапись значения

Если мы вставляем ключ и значение в хэш-мапу, а затем вставляем тот же ключ с другим значением, значение, связанное с этим ключом, будет заменено. Хотя код в Листинге 8-23 дважды вызывает insert, хэш-мапа будет содержать только одну пару ключ-значение, потому что мы оба раза вставляем значение для ключа команды Синие.

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:?}");
}
Listing 8-23: Замена значения, хранящегося по определённому ключу

Этот код выведет {"Blue": 25}. Исходное значение 10 было перезаписано.

Добавление ключа и значения только если ключ отсутствует

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

У хэш-мап есть специальный API для этого под названием entry, который принимает ключ, который вы хотите проверить, в качестве параметра. Возвращаемое значение метода entry — это перечисление Entry, представляющее значение, которое может или не существовать. Допустим, мы хотим проверить, имеет ли ключ для команды Жёлтые связанное значение. Если нет, мы хотим вставить значение 50, и то же самое для команды Синие. Используя 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:?}");
}
Listing 8-24: Использование метода entry для вставки только если ключ ещё не имеет значения

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

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

Обновление значения на основе старого значения

Ещё один распространённый вариант использования хэш-мап — найти значение ключа, а затем обновить его на основе старого значения. Например, Листинг 8-25 показывает код, который подсчитывает, сколько раз каждое слово встречается в некотором тексте. Мы используем хэш-мапу, где слова являются ключами, и увеличиваем значение, чтобы отслеживать, сколько раз мы видели это слово. Если мы видим слово впервые, мы сначала вставим значение 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:?}");
}
Listing 8-25: Подсчёт вхождений слов с использованием хэш-мапы, хранящей слова и их количество

Этот код выведет {"world": 2, "hello": 1, "wonderful": 1}. Вы можете увидеть те же пары ключ-значение, выведенные в другом порядке: вспомните из раздела «Доступ к значениям в хэш-мапе», что перебор хэш-мапы происходит в произвольном порядке.

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

Хэш-функции

По умолчанию HashMap использует хэш-функцию под названием SipHash, которая может обеспечить устойчивость к атакам типа «отказ в обслуживании» (DoS), связанным с хэш-таблицами1. Это не самая быстрая хэш-функция, но компромисс в виде лучшей безопасности из-за падения производительности того стоит. Если вы профилируете свой код и обнаруживаете, что хэш-функция по умолчанию слишком медленна для ваших целей, вы можете переключиться на другую, указав другого хэшера. Хэшер — это тип, который реализует типаж BuildHasher. Мы поговорим о типажах и их реализации в Главе 10. Вам не обязательно реализовывать свой хэшер с нуля; на crates.io есть библиотеки, которыми делятся другие пользователи Rust, предоставляющие хэшеры, реализующие многие распространённые алгоритмы хэширования.

Краткий итог

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

  1. Имея список целых чисел, используйте вектор и верните медиану (при сортировке — значение в средней позиции) и моду (значение, которое встречается чаще всего; здесь поможет хэш-мапа) списка.
  2. Преобразуйте строки в «свинскую латынь». Первая согласная каждого слова перемещается в конец слова, и добавляется ay, так что first становится irst-fay. Слова, начинающиеся с гласной, получают в конце hay вместо этого (apple становится apple-hay). Учитывайте детали кодировки UTF-8!
  3. Используя хэш-мапу и векторы, создайте текстовый интерфейс, позволяющий пользователю добавлять имена сотрудников в отдел компании; например, «Add Sally to Engineering» или «Add Amir to Sales». Затем позвольте пользователю получать список всех людей в отделе или всех людей в компании по отделам, отсортированных по алфавиту.

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

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


  1. https://en.wikipedia.org/wiki/SipHash

Инвентаризация владения #2

«Инвентаризация владения» — это серия викторин, проверяющих понимание владения в реальных примерах. Эти примеры вдохновлены распространёнными вопросами о Rust на StackOverflow.

Обработка ошибок

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

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

Большинство языков не различают эти два вида ошибок и обрабатывают их одинаково, используя такие механизмы, как исключения. В Rust нет исключений. Вместо этого существует тип Result<T, E> для восстанавливаемых ошибок и макрос panic!, который останавливает выполнение, когда программа сталкивается с невосстанавливаемой ошибкой. Эта глава сначала охватывает вызов panic!, а затем рассказывает о возврате значений Result<T, E>. Кроме того, мы рассмотрим соображения при принятии решения о попытке восстановления после ошибки или остановке выполнения.

Невосстанавливаемые ошибки с panic!

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

Разворачивание стека или аварийное завершение в ответ на панику

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

Память, которую использовала программа, затем будет очищена операционной системой. Если в вашем проекте необходимо сделать итоговый бинарный файл как можно меньше, вы можете переключиться с разворачивания на аварийное завершение при панике, добавив panic = 'abort' в соответствующие секции [profile] в вашем файле Cargo.toml. Например, если вы хотите аварийно завершать программу при панике в режиме выпуска (release), добавьте это:

[profile.release]
panic = 'abort'

Давайте попробуем вызвать panic! в простой программе:

Filename: 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!, а не к строке нашего кода, которая в конечном итоге привела к этому вызову.

Мы можем использовать трассировку стека (backtrace) функций, из которых произошел вызов panic!, чтобы выяснить, какая часть нашего_code вызывает проблему. Чтобы понять, как использовать трассировку стека при panic!, давайте рассмотрим другой пример и посмотрим, как это выглядит, когда вызов panic! исходит из крейта из-за ошибки в нашем коде, а не из нашего прямого вызова макроса. В листинге 9-1 есть код, который пытается получить доступ к элементу вектора по индексу, выходящему за пределы допустимых индексов.

Filename: src/main.rs
fn main() {
    let v = vec![1, 2, 3];

    v[99];
}
Listing 9-1: Попытка доступа к элементу за пределами вектора, что приведет к вызову panic!

Здесь мы пытаемся получить доступ к 100-му элементу нашего вектора (который имеет индекс 99, так как индексация начинается с нуля), но вектор содержит только три элемента. В такой ситуации Rust вызовет панику. Использование [] предполагает возврат элемента, но если передать недопустимый индекс, не существует элемента, который Rust мог бы вернуть и который был бы корректным.

В C попытка чтения за пределами структуры данных приводит к неопределённому поведению (undefined behavior). Вы можете получить любые данные, находящиеся в памяти, которые соответствуют этому элементу в структуре, даже если память не принадлежит этой структуре. Это называется переполнением буфера при чтении (buffer overread) и может привести к уязвимостям безопасности, если злоумышленник сможет манипулировать индексом таким образом, чтобы прочитать данные, к которым у него не должно быть доступа, хранящиеся после структуры данных.

Чтобы защитить вашу программу от такого рода уязвимостей, если вы пытаетесь прочитать элемент по индексу, который не существует, 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

Эта ошибка указывает на строку 4 нашего main.rs, где мы пытаемся получить доступ к индексу 99 вектора v.

Строка note: сообщает нам, что мы можем установить переменную окружения RUST_BACKTRACE, чтобы получить трассировку стека (backtrace) того, что именно привело к ошибке. Трассировка стека — это список всех функций, которые были вызваны, чтобы достичь этой точки. Трассировки стека в Rust работают так же, как и в других языках: ключ к чтению трассировки — начать сверху и читать, пока не увидите файлы, которые вы написали. Это то место, где проблема возникла. Строки выше этого места — это код, который вызвал ваш код; строки ниже — это код, который вызвал ваш код. Эти строки до и после могут включать код ядра Rust, код стандартной библиотеки или крейты, которые вы используете. Давайте попробуем получить трассировку стека, установив переменную окружения RUST_BACKTRACE в любое значение, кроме 0. Листинг 9-2 показывает вывод, похожий на тот, который вы увидите.

$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
stack backtrace:
   0: rust_begin_unwind
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/std/src/panicking.rs:692:5
   1: core::panicking::panic_fmt
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/core/src/panicking.rs:75:14
   2: core::panicking::panic_bounds_check
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/core/src/panicking.rs:273:5
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/slice/index.rs:274:10
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/slice/index.rs:16:9
   5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/alloc/src/vec/mod.rs:3361:9
   6: panic::main
             at ./src/main.rs:4:6
   7: core::ops::function::FnOnce::call_once
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
Listing 9-2: Трассировка стека, сгенерированная вызовом panic!, отображаемая при установке переменной окружения RUST_BACKTRACE

Это очень много вывода! Точный вывод, который вы увидите, может отличаться в зависимости от вашей операционной системы и версии Rust. Чтобы получать трассировки стека с такой информацией, должны быть включены символы отладки (debug symbols). Символы отладки включены по умолчанию при использовании cargo build или cargo run без флага --release, как мы и сделали.

В выводе в листинге 9-2 строка 6 трассировки стека указывает на строку в нашем проекте, которая вызывает проблему: строку 4 файла src/main.rs. Если мы не хотим, чтобы наша программа паниковала, мы должны начать расследование в месте, на которое указывает первая строка, упоминающая файл, который мы написали. В листинге 9-1, где мы намеренно написали код, который вызовет панику, способ исправить панику — не запрашивать элемент за пределами диапазона индексов вектора. Когда ваш код паникует в будущем, вам нужно будет выяснить, какое действие выполняет код с какими значениями, что вызывает панику, и что код должен делать вместо этого.

Мы вернемся к panic! и к тому, когда мы должны и не должны использовать panic! для обработки условий ошибок, в разделе «panic! или не panic!» позже в этой главе. Далее мы посмотрим, как восстанавливаться после ошибки с помощью Result.

Восстанавливаемые ошибки с Result

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

Вспомните из главы 2 “Обработка потенциального сбоя с Result, что перечисление Result определено с двумя вариантами, Ok и Err, следующим образом:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

T и E — это параметры обобщённых типов: мы обсудим обобщения подробнее в главе 10. Сейчас вам нужно знать, что T представляет тип значения, которое будет возвращено в случае успеха внутри варианта Ok, а E представляет тип ошибки, который будет возвращён в случае сбоя внутри варианта Err. Поскольку Result имеет эти параметры обобщённых типов, мы можем использовать тип Result и определенные на нём функции во многих различных ситуациях, где возвращаемые значения успеха и ошибки могут отличаться.

Вызовем функцию, возвращающую значение Result, потому что функция может завершиться с ошибкой. В листинге 9-3 мы пытаемся открыть файл.

Filename: src/main.rs
use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");
}
Listing 9-3: Открытие файла

Тип возвращаемого значения File::open — это Result<T, E>. Параметр обобщённого типа T был заполнен реализацией File::open типом значения успеха, std::fs::File, который является дескриптором файла. Тип E, используемый в значении ошибки, — это std::io::Error. Этот тип возврата означает, что вызов File::open может завершиться успешно и вернуть дескриптор файла, из которого можно читать или в который можно писать. Вызов функции также может завершиться с ошибкой: например, файл может не существовать или у вас может не быть прав доступа к файлу. Функция File::open должна иметь способ сообщить нам, succeeded ли она, и одновременно дать либо дескриптор файла, либо информацию об ошибке. Эта информация именно то, что передаёт перечисление Result.

В случае, когда File::open завершается успешно, значение в переменной greeting_file_result будет экземпляром Ok, содержащим дескриптор файла. В случае, когда она завершается с ошибкой, значение в greeting_file_result будет экземпляром Err, содержащим дополнительную информацию о том, какая ошибка произошла.

Нам нужно добавить в код из листинга 9-3 логику для выполнения различных действий в зависимости от возвращаемого значения File::open. Листинг 9-4 показывает один из способов обработки Result с помощью базового инструмента — выражения match, которое мы обсуждали в главе 6.

Filename: 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:?}"),
    };
}
Listing 9-4: Использование выражения match для обработки вариантов Result, которые могут быть возвращены

Обратите внимание, что, как и перечисление Option, перечисление Result и его варианты были импортированы в область видимости прелюдией, поэтому нам не нужно указывать 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

Как обычно, этот вывод точно сообщает нам, что пошло не так.

Сопоставление с разными ошибками

Код в листинге 9-4 вызовет panic! независимо от причины сбоя File::open. Однако мы хотим предпринимать разные действия для разных причин сбоя. Если File::open завершился с ошибкой потому, что файл не существует, мы хотим создать файл и вернуть дескриптор нового файла. Если File::open завершился с ошибкой по любой другой причине — например, потому что у нас нет прав на открытие файла — мы всё ещё хотим, чтобы код вызвал panic! так же, как это было в листинге 9-4. Для этого мы добавляем вложенное выражение match, показанное в листинге 9-5.

Filename: 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:?}"),
            },
            _ => {
                panic!("Problem opening the file: {error:?}");
            }
        },
    };
}
Listing 9-5: Обработка разных видов ошибок разными способами

Тип значения, которое 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 остаётся той же, поэтому программа аварийно завершается при любой ошибке, кроме ошибки отсутствующего файла.

Альтернативы использованию match с Result<T, E>

Это много match! Выражение match очень полезно, но также является примитивом. В главе 13 вы узнаете о замыканиях, которые используются со многими методами, определенными на 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 в документации стандартной библиотеки. Многие другие такие методы могут очистить огромные вложенные выражения match при работе с ошибками.

Ярлыки для паники при ошибке: unwrap и expect

Использование match работает достаточно хорошо, но оно может быть немного многословным и не всегда хорошо передаёт намерение. Тип Result<T, E> имеет много вспомогательных методов, определённых на нём для выполнения различных, более конкретных задач. Метод unwrap — это ярлык, реализованный точно так же, как выражение match, которое мы написали в листинге 9-4. Если значение Result — это вариант Ok, unwrap вернёт значение внутри Ok. Если Result — это вариант Err, unwrap вызовет для нас макрос panic!. Вот пример unwrap в действии:

Filename: 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 src/main.rs:4:49:
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }

Аналогично, метод expect позволяет нам также выбрать сообщение об ошибке panic!. Использование expect вместо unwrap и предоставление хороших сообщений об ошибках может передать ваше намерение и облегчить отслеживание источника паники. Синтаксис expect выглядит так:

Filename: src/main.rs
use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")
        .expect("hello.txt should be included in this project");
}

Мы используем expect так же, как unwrap: чтобы вернуть дескриптор файла или вызвать макрос panic!. Сообщение об ошибке, которое использует expect в своём вызове panic!, будет параметром, который мы передаём в expect, а не стандартным сообщением panic!, которое использует unwrap. Вот как это выглядит:

thread 'main' panicked at src/main.rs:5:10:
hello.txt should be included in this project: Os { code: 2, kind: NotFound, message: "No such file or directory" }

В коде production-quality большинство Rustaceans выбирают expect вместо unwrap и дают больше контекста о том, почему операция должна всегда завершаться успешно. Таким образом, если ваши предположения когда-либо окажутся неверными, у вас будет больше информации для отладки.

Распространение ошибок

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

Например, листинг 9-6 показывает функцию, которая читает имя пользователя из файла. Если файл не существует или не может быть прочитан, эта функция вернёт эти ошибки в код, который вызвал функцию.

Filename: 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),
    }
}
}
Listing 9-6: Функция, которая возвращает ошибки в вызывающий код с использованием match

Эту функцию можно написать гораздо короче, но мы начнём с того, что сделаем многое вручную, чтобы исследовать обработку ошибок; в конце мы покажем более короткий способ. Сначала посмотрим на тип возврата функции: Result<String, io::Error>. Это означает, что функция возвращает значение типа Result<T, E>, где параметр обобщённого типа T был заполнен конкретным типом String, а параметр обобщённого типа E был заполнен конкретным типом io::Error.

Если эта функция завершается успешно без каких-либо проблем, код, который вызывает эту функцию, получит значение Ok, содержащее Stringusername, который эта функция прочитала из файла. Если эта функция столкнётся с какими-либо проблемами, вызывающий код получит значение 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, но эта реализация использует оператор ?.

Filename: 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)
}
}
Listing 9-7: Функция, которая возвращает ошибки в вызывающий код с использованием оператора ?

?, помещённый после значения 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.

Filename: 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)
}
}
Listing 9-8: Цепочка вызовов методов после оператора ?

Мы переместили создание новой 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.

Filename: 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")
}
}
Listing 9-9: Использование fs::read_to_string вместо открытия, а затем чтения файла

Чтение файла в строку — довольно распространённая операция, поэтому стандартная библиотека предоставляет удобную функцию fs::read_to_string, которая открывает файл, создаёт новую String, читает содержимое файла, помещает содержимое в эту String и возвращает её. Конечно, использование fs::read_to_string не даёт нам возможности объяснить всю обработку ошибок, поэтому мы сделали это более длинным способом сначала.

Где можно использовать оператор ?

Оператор ? можно использовать только в функциях, тип возврата которых совместим со значением, на котором используется ?. Это потому, что оператор ? определён для выполнения досрочного возврата значения из функции, так же как выражение match, которое мы определили в листинге 9-6. В листинге 9-6 match использовал значение Result, а ветвь досрочного возврата возвращала значение Err(e). Тип возврата функции должен быть Result, чтобы быть совместимым с этим return.

В листинге 9-10 посмотрим на ошибку, которую мы получим, если используем оператор ? в функции main с типом возврата, несовместимым с типом значения, на котором мы используем ?.

Filename: src/main.rs
use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")?;
}
Listing 9-10: Попытка использовать ? в функции main, которая возвращает (), не скомпилируется.

Этот код открывает файл, что может завершиться с ошибкой. Оператор ? следует за значением Result, возвращаемым File::open, но эта функция main имеет тип возврата (), а не Result. Когда мы компилируем этот код, мы получаем следующее сообщение об ошибке:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> src/main.rs:4:48
  |
3 | fn main() {
  | --------- 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);
}
Listing 9-11: Использование оператора ? на значении Option<T>

Эта функция возвращает Option<char>, потому что возможно, что символ там есть, но также возможно, что его нет. Этот код принимает аргумент text — срез строки и вызывает метод lines на нём, который возвращает итератор по строкам в строке. Поскольку эта функция хочет проверить первую строку, она вызывает next на итераторе, чтобы получить первое значение из итератора. Если text — пустая строка, этот вызов next вернёт None, и в этом случае мы используем ?, чтобы остановиться и вернуть None из last_char_of_first_line. Если text — не пустая строка, next вернёт значение Some, содержащее срез строки первой строки в text.

? извлекает срез строки, и мы можем вызвать chars на этом срезе строки, чтобы получить итератор его символов. Нас интересует последний символ в этой первой строке, поэтому мы вызываем last, чтобы вернуть последний элемент в итераторе. Это Option, потому что возможно, что первая строка — пустая строка; например, если text начинается с пустой строки, но имеет символы в других строках, как в "\nhi". Однако, если есть последний символ в первой строке, он будет возвращён в варианте Some. Оператор ? в середине даёт нам краткий способ выразить эту логику, позволяя реализовать функцию в одной строке. Если бы мы не могли использовать оператор ? на Option, нам пришлось бы реализовать эту логику, используя больше вызовов методов или выражение match.

Обратите внимание, что вы можете использовать оператор ? на Result в функции, которая возвращает Result, и вы можете использовать оператор ? на Option в функции, которая возвращает Option, но вы не можете смешивать. Оператор ? не будет автоматически преобразовывать Result в Option или наоборот; в этих случаях вы можете использовать методы, такие как метод ok на Result или метод ok_or на Option, чтобы сделать преобразование явным.

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

К счастью, main также может возвращать Result<(), E>. Листинг 9-12 имеет код из листинга 9-10, но мы изменили тип возврата main на Result<(), Box<dyn Error>> и добавили возвращаемое значение Ok(()) в конец. Этот код теперь скомпилируется.

Filename: src/main.rs
use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let greeting_file = File::open("hello.txt")?;

    Ok(())
}
Listing 9-12: Изменение main на возврат Result<(), E> позволяет использовать оператор ? на значениях Result.

Тип Box<dyn Error> — это объект типажа, о котором мы поговорим в “Использовании объектов типажей, которые позволяют иметь значения разных типов” в главе 18. Пока вы можете читать 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!, а когда возвращать Result? Когда код паникует, восстановиться невозможно. Вы можете вызвать panic! для любой ситуации ошибки, независимо от того, есть ли возможность восстановления, но тогда вы принимаете решение от имени вызывающего кода, что ситуация невосстановима. Когда вы выбираете возврат значения Result, вы даёте вызывающему коду варианты. Вызывающий код может попытаться восстановиться подходящим для своей ситуации способом или решить, что значение Err в этом случае невосстановимо, поэтому он может вызвать panic! и превратить вашу восстанавливаемую ошибку в невосстанавливаемую. Следовательно, возврат Result — хороший выбор по умолчанию при определении функции, которая может завершиться неудачей.

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

Примеры, прототипный код и тесты

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

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

Если вызов метода завершится неудачей в тесте, вы захотите, чтобы весь тест провалился, даже если этот метод не является тестируемой функциональностью. Поскольку panic! — это способ пометить тест как проваленный, вызов unwrap или expect — именно то, что должно произойти.

Случаи, в которых у вас больше информации, чем у компилятора

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

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-адрес из другого источника.

Рекомендации по обработке ошибок

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

  • Плохое состояние — это нечто неожиданное, в отличие от того, что может случаться время от времени, например, пользователь вводит данные в неправильном формате.
  • Ваш код после этой точки должен полагаться на то, что он не находится в этом плохом состоянии, а не проверять проблему на каждом шаге.
  • Нет хорошего способа закодировать эту информацию в используемых вами типах. Мы разберём пример того, что мы имеем в виду, в разделе «Кодирование состояний и поведения как типов» в главе 18.

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

Однако, когда сбой ожидаем, более уместно возвращать Result, чем делать вызов panic!. Примеры включают парсер, которому переданы некорректные данные, или HTTP-запрос, возвращающий статус, указывающий, что вы достигли лимита запросов. В этих случаях возврат Result указывает, что сбой — это ожидаемая возможность, которую вызывающий код должен решить, как обрабатывать.

Когда ваш код выполняет операцию, которая может поставить пользователя под угрозу, если она вызывается с использованием невалидных значений, ваш код должен сначала проверить валидность значений и паниковать, если значения невалидны. Это в основном по соображениям безопасности: попытка работы с невалидными данными может подвергнуть ваш код уязвимостям. Это основная причина, по которой стандартная библиотека вызовет panic!, если вы пытаетесь получить доступ к памяти за пределами границ: попытка доступа к памяти, которая не принадлежит текущей структуре данных, — это распространённая проблема безопасности. Функции часто имеют контракты: их поведение гарантировано только если входные данные соответствуют определённым требованиям. Паника при нарушении контракта имеет смысл, потому что нарушение контракта всегда указывает на ошибку со стороны вызывающего кода, и это не тот вид ошибки, который вы хотите, чтобы вызывающий код обрабатывал явно. Фактически, нет разумного способа для вызывающего кода восстановиться; вызывающие программисты должны исправить код. Контракты для функции, особенно когда нарушение вызовет панику, должны быть объяснены в документации API для функции.

Однако иметь множество проверок ошибок во всех ваших функциях было бы многословно и раздражающе. К счастью, вы можете использовать систему типов Rust (и, следовательно, проверку типов, выполняемую компилятором), чтобы выполнять многие проверки за вас. Если ваша функция имеет определённый тип в качестве параметра, вы можете продолжить логику вашего кода, зная, что компилятор уже обеспечил валидное значение. Например, если у вас есть тип, а не Option, ваша программа ожидает иметь что-то, а не ничего. Ваш код тогда не должен обрабатывать два случая для вариантов Some и None: у него будет только один случай для гарантированного наличия значения. Код, пытающийся передать ничего в вашу функцию, даже не скомпилируется, поэтому вашей функции не нужно проверять этот случай во время выполнения. Другой пример — использование беззнакового целочисленного типа, такого как u32, который гарантирует, что параметр никогда не будет отрицательным.

Создание пользовательских типов для проверки

Развим идею использования системы типов Rust для обеспечения валидного значения на шаг дальше и рассмотрим создание пользовательского типа для проверки. Вспомните игру-угадайку в главе 2, в которой наш код просил пользователя угадать число между 1 и 100. Мы никогда не проверяли, что догадка пользователя была между этими числами перед проверкой её против нашего секретного числа; мы проверяли только, что догадка была положительной. В этом случае последствия были не очень серьёзными: наш вывод «Слишком высоко» или «Слишком низко» всё равно был бы правильным. Но было бы полезным улучшением направлять пользователя к валидным догадкам и иметь разное поведение, когда пользователь угадывает число вне диапазона, в отличие от того, когда пользователь вводит, например, буквы.

Один из способов сделать это — разобрать догадку как i32 вместо только u32, чтобы разрешить потенциально отрицательные числа, а затем добавить проверку на то, что число находится в диапазоне, вот так:

Filename: 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 {
        // --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.

Filename: src/guessing_game.rs
#![allow(unused)]
fn main() {
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}
Listing 9-13: Тип Guess, который продолжит работу только со значениями между 1 и 100

Обратите внимание, что этот код в src/guessing_game.rs зависит от добавления объявления модуля mod guessing_game; в src/lib.rs, которое мы здесь не показали. В файле этого нового модуля мы определяем структуру в этом модуле с именем Guess, которая имеет поле с именем value, которое хранит i32. Здесь будет храниться число.

Затем мы реализуем ассоциированную функцию с именем new для Guess, которая создаёт экземпляры значений 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. Такой метод иногда называется геттером, потому что его цель — получить некоторые данные из его полей и вернуть их. Этот публичный метод необходим, потому что поле value структуры Guess является приватным. Важно, чтобы поле value было приватным, чтобы код, использующий структуру Guess, не мог устанавливать value напрямую: код вне модуля guessing_game должен использовать функцию 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): разновидность обобщений, которые дают компилятору информацию о том, как ссылки соотносятся друг с другом. Время жизни позволяет дать компилятору достаточно сведений о заимствованных значениях, чтобы он мог гарантировать, что ссылки будут действительными в большем числе ситуаций, чем мог бы без нашей помощи.

Устранение дублирования путём выделения функции

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

Начнём с короткой программы в Листинге 10-1, которая находит наибольшее число в списке.

Filename: 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);
}
Listing 10-1: Поиск наибольшего числа в списке чисел

Мы храним список целых чисел в переменной number_list и помещаем ссылку на первое число списка в переменную с именем largest. Затем мы перебираем все числа в списке, и если текущее число больше числа, хранящегося в largest, мы заменяем ссылку в этой переменной. Однако, если текущее число меньше или равно наибольшему числу,seen so far, переменная не изменяется, и код переходит к следующему числу в списке. После рассмотрения всех чисел в списке largest должна ссылаться на наибольшее число, которое в данном случае равно 100.

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

Filename: 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}");
}
Listing 10-2: Код для поиска наибольшего числа в двух списках чисел

Хотя этот код работает, дублирование кода утомительно и склонно к ошибкам. Кроме того, когда мы хотим изменить код, мы должны помнить обновлять его в нескольких местах.

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

В Листинге 10-3 мы выделяем код, находящий наибольшее число, в функцию с именем largest. Затем вызываем эту функцию для поиска наибольшего числа в двух списках из Листинга 10-2. Мы также могли бы использовать эту функцию для любого другого списка значений i32, который у нас может появиться в будущем.

Filename: 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);
}
Listing 10-3: Абстрагированный код для поиска наибольшего числа в двух списках

Функция largest имеет параметр с именем list, который представляет любой конкретный срез значений i32, который мы могли бы передать в функцию. В результате, когда мы вызываем функцию, код выполняется на конкретных значениях, которые мы передаём.

Вкратце, вот шаги, которые мы предприняли, чтобы изменить код из Листинга 10-2 в Листинг 10-3:

  1. Определите дублирующийся код.
  2. Выделите дублирующийся код в тело функции и укажите входные данные и возвращаемые значения этого кода в сигнатуре функции.
  3. Обновите два экземпляра дублирующегося кода, заменив их вызовом функции.

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

Например, допустим, у нас есть две функции: одна находит наибольший элемент в срезе значений i32, а другая находит наибольший элемент в срезе значений char. Как нам устранить это дублирование? Давайте выясним!

Обобщённые типы данных

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

В определениях функций

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

Продолжая с функцией largest, Листинг 10-4 показывает две функции, которые обе находят наибольшее значение в срезе. Затем мы объединим их в одну функцию, использующую обобщения.

Filename: 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');
}
Listing 10-4: Две функции, которые отличаются только именами и типами в их сигнатурах

Функция largest_i32 — это та, которую мы извлекли в Листинге 10-3, которая находит наибольшее i32 в срезе. Функция largest_char находит наибольший char в срезе. Тела функций имеют одинаковый код, поэтому давайте устраним дублирование, представив параметр обобщённого типа в одной функции.

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

Когда мы используем параметр в теле функции, мы должны объявить имя параметра в сигнатуре, чтобы компилятор знал, что означает это имя. Аналогично, когда мы используем имя параметра типа в сигнатуре функции, мы должны объявить имя параметра типа перед его использованием. Чтобы определить обобщённую функцию largest, мы размещаем объявления имён типов внутри угловых скобок, <>, между именем функции и списком параметров, вот так:

fn largest<T>(list: &[T]) -> &T {

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

Листинг 10-5 показывает объединённое определение функции largest, использующее обобщённый тип данных в своей сигнатуре. Листинг также показывает, как мы можем вызвать функцию либо со срезом значений i32, либо значений char. Обратите внимание, что этот код пока не скомпилируется.

Filename: 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}");
}
Listing 10-5: Функция largest, использующая параметры обобщённого типа; это пока не компилируется

Если мы скомпилируем этот код прямо сейчас, мы получим эту ошибку:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
 --> src/main.rs:5:17
  |
5 |         if item > largest {
  |            ---- ^ ------- &T
  |            |
  |            &T
  |
help: consider restricting type parameter `T` with trait `PartialOrd`
  |
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

Проблема выше в том, что когда largest принимает срез &[T] на входе, функция не может предполагать ничего о типе T. Это может быть i32, это может быть String, это может быть File. Однако largest требует, чтобы T был чем-то, с чем можно сравнить с помощью > (т.е. чтобы T реализовывал PartialOrd, типаж, который мы обсудим в следующем разделе). Некоторые типы, такие как i32 и String, сравнимы, но другие типы, такие как File, несравнимы.

В языке, подобном C++, с шаблонами, компилятор не стал бы жаловаться на реализацию largest, а вместо этого стал бы жаловаться на попытку вызова largest для, например, среза файлов &[File]. Rust вместо этого требует, чтобы вы заранее заявили ожидаемые возможности обобщённых типов. Если T должен быть сравнимым, то largest должен это указать. Поэтому эта ошибка компиляции говорит, что largest не скомпилируется, пока T не будет ограничен.

Кроме того, в отличие от языков, подобных Java, где все объекты имеют набор основных методов, таких как Object.toString(), в Rust нет основных методов. Без ограничений обобщённый тип T не имеет возможностей: его нельзя вывести, скопировать или изменить (хотя его можно удалить).

В определениях структур

Мы также можем определять структуры, чтобы использовать параметр обобщённого типа в одном или нескольких полях, используя синтаксис <>. Листинг 10-6 определяет структуру Point<T> для хранения значений координат x и y любого типа.

Filename: 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 };
}
Listing 10-6: Структура Point<T>, хранящая значения x и y типа T

Синтаксис использования обобщений в определениях структур аналогичен используемому в определениях функций. Сначала мы объявляем имя параметра типа внутри угловых скобок сразу после имени структуры. Затем мы используем обобщённый тип в определении структуры, где обычно указывали бы конкретные типы данных.

Обратите внимание, что поскольку мы использовали только один обобщённый тип для определения Point<T>, это определение говорит, что структура Point<T> обобщена относительно некоторого типа T, и поля x и y — это оба один и тот же тип, каким бы он ни был. Если мы создадим экземпляр Point<T>, имеющий значения разных типов, как в Листинге 10-7, наш код не скомпилируется.

Filename: src/main.rs
struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}
Listing 10-7: Поля x и y должны быть одного типа, потому что оба имеют один и тот же обобщённый тип данных T.

В этом примере, когда мы присваиваем целочисленное значение 5 переменной x, мы сообщаем компилятору, что обобщённый тип T будет целым числом для этого экземпляра Point<T>. Затем, когда мы указываем 4.0 для y, который мы определили как имеющий тот же тип, что и x, мы получим ошибку несоответствия типов, подобную этой:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
 --> src/main.rs:7:38
  |
7 |     let wont_work = Point { x: 5, y: 4.0 };
  |                                      ^^^ expected integer, found floating-point number

For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

Чтобы определить структуру Point, где x и y являются обобщёнными, но могут иметь разные типы, мы можем использовать несколько параметров обобщённого типа. Например, в Листинге 10-8 мы меняем определение Point на обобщённое относительно типов T и U, где x имеет тип T, а y имеет тип U.

Filename: 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 };
}
Listing 10-8: Point<T, U>, обобщённое относительно двух типов, так что x и y могут быть значениями разных типов

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

В определениях перечислений

Как и со структурами, мы можем определять перечисления, чтобы хранить обобщённые типы данных в их вариантах. Давайте ещё раз посмотрим на перечисление Option<T> из стандартной библиотеки, которое мы использовали в Главе 6:

#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

Теперь это определение должно быть более понятным для вас. Как вы можете видеть, перечисление Option<T> обобщено относительно типа T и имеет два варианта: Some, который хранит одно значение типа T, и вариант None, который не хранит никакого значения. Используя перечисление Option<T>, мы можем выразить абстрактную концепцию необязательного значения, и поскольку Option<T> обобщено, мы можем использовать эту абстракцию независимо от типа необязательного значения.

Перечисления также могут использовать несколько обобщённых типов. Определение перечисления Result, которое мы использовали в Главе 9, — один из примеров:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

Перечисление Result обобщено относительно двух типов, T и E, и имеет два варианта: Ok, который хранит значение типа T, и Err, который хранит значение типа E. Это определение позволяет удобно использовать перечисление Result везде, где у нас есть операция, которая может succeeded (вернуть значение некоторого типа T) или failed (вернуть ошибку некоторого типа E). Фактически, это то, что мы использовали для открытия файла в Листинге 9-3, где T был заполнен типом std::fs::File при успешном открытии файла, а E был заполнен типом std::io::Error при проблемах с открытием файла.

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

В определениях методов

Мы можем реализовывать методы на структурах и перечислениях (как мы делали в Главе 5) и использовать обобщённые типы в их определениях. Листинг 10-9 показывает структуру Point<T>, которую мы определили в Листинге 10-6, с методом с именем x, реализованным для неё.

Filename: 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());
}
Listing 10-9: Реализация метода с именем x для структуры Point<T>, который вернёт ссылку на поле x типа T

Здесь мы определили метод с именем x для Point<T>, который возвращает ссылку на данные в поле x.

Обратите внимание, что мы должны объявить T сразу после impl, чтобы мы могли использовать T для указания того, что реализуем методы для типа Point<T>. Объявляя T как обобщённый тип после impl, Rust может определить, что тип в угловых скобках в Point является обобщённым типом, а не конкретным типом. Мы могли бы выбрать другое имя для этого параметра обобщённого типа, отличное от параметра обобщённого типа, объявленного в определении структуры, но использование того же имени является общепринятым. Если вы пишете метод внутри impl, который объявляет обобщённый тип, этот метод будет определён для любого экземпляра типа, независимо от того, какой конкретный тип в конечном итоге подставится вместо обобщённого типа.

Мы также можем указывать ограничения на обобщённые типы при определении методов для типа. Мы могли бы, например, реализовать методы только для экземпляров Point<f32>, а не для экземпляров Point<T> с любым обобщённым типом. В Листинге 10-10 мы используем конкретный тип f32, что означает, что мы не объявляем никакие типы после impl.

Filename: 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());
}
Listing 10-10: Блок impl, который применяется только к структуре с конкретным типом для параметра обобщённого типа T

Этот код означает, что тип Point<f32> будет иметь метод distance_from_origin; другие экземпляры Point<T>, где T не является типом f32, не будут иметь этого метода определённым. Метод измеряет, как далеко наша точка находится от точки с координатами (0.0, 0.0) и использует математические операции, доступные только для типов с плавающей запятой.

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

Параметры обобщённого типа в определении структуры не всегда совпадают с теми, которые вы используете в сигнатурах методов этой же структуры. Листинг 10-11 использует обобщённые типы X1 и Y1 для структуры Point и X2 Y2 для сигнатуры метода mixup, чтобы сделать пример более ясным. Метод создаёт новый экземпляр Point со значением x из self Point (типа X1) и значением y из переданного Point (типа Y2).

Filename: 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);
}
Listing 10-11: Метод, использующий обобщённые типы, отличные от определения его структуры

В 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, тем самым заменяя обобщённое определение конкретными.

Мономорфизированная версия кода выглядит подобно следующей (компилятор использует другие имена, чем те, которые мы используем здесь, для иллюстрации):

Filename: 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 чрезвычайно эффективными во время выполнения.

Типажи: Определение общей функциональности

Типаж определяет функциональность, которой обладает конкретный тип и которой он может делиться с другими типами. Мы можем использовать типажи для абстрактного определения общего поведения. Мы можем использовать ограничения типажей (trait bounds), чтобы указать, что обобщённый тип может быть любым типом, обладающим определённым поведением.

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

Определение типажа

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

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

Мы хотим создать библиотечный крейт медиа-агрегатора с именем aggregator, который может отображать сводки данных, которые могут храниться в экземпляре NewsArticle или SocialPost. Для этого нам нужна сводка от каждого типа, и мы запросим эту сводку, вызвав метод summarize на экземпляре. Листинг 10-12 показывает определение публичного типажа Summary, выражающего это поведение.

Filename: src/lib.rs
pub trait Summary {
    fn summarize(&self) -> String;
}
Listing 10-12: Типаж Summary, состоящий из поведения, предоставляемого методом summarize

Здесь мы объявляем типаж с помощью ключевого слова trait, а затем имя типажа, которое в данном случае Summary. Мы также объявляем типаж как pub, чтобы крейты, зависящие от этого крейта, также могли использовать этот типаж, как мы увидим в нескольких примерах. Внутри фигурных скобок мы объявляем сигнатуры методов, которые описывают поведения типов, реализующих этот типаж, что в данном случае — fn summarize(&self) -> String.

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

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

Реализация типажа для типа

Теперь, когда мы определили желаемые сигнатуры методов типажа Summary, мы можем реализовать его для типов в нашем медиа-агрегаторе. Листинг 10-13 показывает реализацию типажа Summary для структуры NewsArticle, которая использует заголовок, автора и местоположение для создания возвращаемого значения summarize. Для структуры SocialPost мы определяем summarize как имя пользователя, за которым следует весь текст поста, предполагая, что содержание поста уже ограничено 280 символами.

Filename: 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 SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}
Listing 10-13: Реализация типажа Summary для типов NewsArticle и SocialPost

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

Теперь, когда библиотека реализовала типаж Summary для NewsArticle и SocialPost, пользователи крейта могут вызывать методы типажа на экземплярах NewsArticle и SocialPost так же, как мы вызываем обычные методы. Единственное отличие в том, что пользователь должен также импортировать в область видимости и типаж, и типы. Вот пример того, как бинарный крейт мог бы использовать наш библиотечный крейт aggregator:

use aggregator::{SocialPost, Summary};

fn main() {
    let post = SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    };

    println!("1 new post: {}", post.summarize());
}

Этот код выводит 1 new post: horse_ebooks: of course, as you probably already know, people.

Другие крейты, зависящие от крейта aggregator, также могут импортировать типаж Summary в область видимости, чтобы реализовать Summary для своих собственных типов. Одно ограничение, на которое стоит обратить внимание: мы можем реализовать типаж для типа только в том случае, если либо типаж, либо тип, или оба, являются локальными для нашего крейта. Например, мы можем реализовать стандартные библиотечные типажи, такие как Display, для пользовательского типа, такого как SocialPost, в рамках функциональности нашего крейта aggregator, потому что тип SocialPost локальен для нашего крейта 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.

Filename: 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 SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}
Listing 10-14: Определение типажа Summary со стандартной реализацией метода summarize

Чтобы использовать стандартную реализацию для сводки экземпляров NewsArticle, мы указываем пустой блок impl с impl Summary for NewsArticle {}.

Хотя мы больше не определяем метод summarize на NewsArticle напрямую, мы предоставили стандартную реализацию и указали, что NewsArticle реализует типаж Summary. В результате мы всё ещё можем вызывать метод summarize на экземпляре NewsArticle, вот так:

use aggregator::{self, NewsArticle, Summary};

fn main() {
    let article = NewsArticle {
        headline: String::from("Penguins win the Stanley Cup Championship!"),
        location: String::from("Pittsburgh, PA, USA"),
        author: String::from("Iceburgh"),
        content: String::from(
            "The Pittsburgh Penguins once again are the best \
             hockey team in the NHL.",
        ),
    };

    println!("New article available! {}", article.summarize());
}

Этот код выводит New article available! (Read more...).

Создание стандартной реализации не требует от нас изменять что-либо в реализации Summary на SocialPost в Листинге 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 SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    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 SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

После того как мы определим summarize_author, мы можем вызывать summarize на экземплярах структуры SocialPost, и стандартная реализация summarize вызовет определение summarize_author, которое мы предоставили. Поскольку мы реализовали summarize_author, типаж Summary дал нам поведение метода summarize без необходимости писать больше кода. Вот как это выглядит:

use aggregator::{self, SocialPost, Summary};

fn main() {
    let post = SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    };

    println!("1 new post: {}", post.summarize());
}

Этот код выводит 1 new post: (Read more from @horse_ebooks...).

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

Типажи как параметры

Теперь, когда вы знаете, как определять и реализовывать типажи, мы можем исследовать, как использовать типажи для определения функций, принимающих множество различных типов. Мы будем использовать типаж Summary, который мы реализовали для типов NewsArticle и SocialPost в Листинге 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 SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    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 или SocialPost. Код, который вызывает функцию с любым другим типом, таким как String или i32, не скомпилируется, потому что эти типы не реализуют Summary.

Синтаксис ограничения типажа (Trait Bound Syntax)

Синтаксис 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 SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable() -> impl Summary {
    SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    }
}

Используя impl Summary для возвращаемого типа, мы указываем, что функция returns_summarizable возвращает некоторый тип, реализующий типаж Summary, не называя конкретный тип. В этом случае returns_summarizable возвращает SocialPost, но код, вызывающий эту функцию, не обязательно должен это знать.

Возможность указать возвращаемый тип только по тому типажу, который он реализует, особенно полезна в контексте замыканий и итераторов, которые мы рассматриваем в Главе 13. Замыкания и итераторы создают типы, которые известны только компилятору, или типы, которые очень длинно указывать. Синтаксис impl Trait позволяет вам кратко указать, что функция возвращает некоторый тип, реализующий типаж Iterator, без необходимости писать очень длинный тип.

Однако вы можете использовать impl Trait только если возвращаете один тип. Например, этот код, который возвращает либо NewsArticle, либо SocialPost с возвращаемым типом, указанным как 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 SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    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 {
        SocialPost {
            username: String::from("horse_ebooks"),
            content: String::from(
                "of course, as you probably already know, people",
            ),
            reply: false,
            repost: false,
        }
    }
}

Возврат либо NewsArticle, либо SocialPost не разрешён из-за ограничений вокруг того, как синтаксис impl Trait реализован в компиляторе. Мы рассмотрим, как написать функцию с таким поведением в разделе “Использование объектов типажей, которые допускают значения разных типов” Главы 18.

Использование ограничений типажей для условной реализации методов

Используя ограничение типажа с блоком impl, который использует параметры обобщённого типа, мы можем реализовывать методы условно для типов, которые реализуют указанные типажи. Например, тип Pair<T> в Листинге 10-15 всегда реализует функцию new, чтобы возвращать новый экземпляр Pair<T> (вспомните из раздела “Определение методов” Главы 5, что Self — это псевдоним типа для типа блока impl, который в данном случае — Pair<T>). Но в следующем блоке impl Pair<T> реализует метод cmp_display только если его внутренний тип T реализует типаж PartialOrd, который позволяет сравнивать, и типаж Display, который позволяет выводить.

Filename: 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);
        }
    }
}
Listing 10-15: Условная реализация методов на обобщённом типе в зависимости от ограничений типажей

Мы также можем условно реализовывать типаж для любого типа, который реализует другой типаж. Реализации типажа для любого типа, удовлетворяющего ограничениям типажей, называются шаблонными реализациями (blanket implementations) и широко используются в стандартной библиотеке 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 имеет время жизни, то есть область видимости, в течение которой эта ссылка валидна. Чаще всего время жизни неявное и выводится, подобно тому как чаще всего выводятся типы. Мы обязаны аннотировать типы только когда возможны несколько типов. Аналогично, мы должны аннотировать время жизни, когда время жизни ссылок может быть связано несколькими разными способами. Rust требует от нас аннотировать эти связи с помощью параметров обобщённого времени жизни, чтобы гарантировать, что фактические ссылки, используемые во время выполнения, безусловно будут валидными.

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

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

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

fn main() {
    let r;

    {
        let x = 5;
        r = &x;
    }

    println!("r: {}", r);
}
Listing 10-16: Попытка использовать ссылку, значение которой вышло из области видимости

Примечание: Примеры в Листингах 10-16, 10-17 и 10-23 объявляют переменные без начального значения, поэтому имя переменной существует во внешней области видимости. На первый взгляд это может показаться противоречащим отсутствию в Rust пустых значений. Однако, если мы попытаемся использовать переменную до того, как присвоим ей значение, мы получим ошибку компиляции, которая показывает, что Rust действительно не разрешает пустые значения.

Внешняя область видимости объявляет переменную 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 определяет, что этот код невалиден? Он использует проверку заимствований.

Проверка заимствований гарантирует, что данные переживают свои ссылки

У компилятора Rust есть проверка заимствований, которая сравнивает области видимости, чтобы определить, являются ли все заимствования валидными. Листинг 10-17 показывает тот же код, что и Листинг 10-16, но с аннотациями, показывающими время жизни переменных.

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {r}");   //          |
}                         // ---------+
Listing 10-17: Аннотации времени жизни r и x, названные 'a и 'b соответственно

Здесь мы аннотировали время жизни 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}");   //   |       |
                          // --+       |
}                         // ----------+
Listing 10-18: Валидная ссылка, потому что данные имеют более долгое время жизни, чем ссылка

Здесь x имеет время жизни 'b, которое в данном случае больше, чем 'a. Это означает, что r может ссылаться на x, потому что Rust знает, что ссылка в r всегда будет валидной, пока x валидна.

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

Обобщённое время жизни в функциях

Мы напишем функцию, которая возвращает более длинную из двух строковых срезов. Эта функция будет принимать два строковых среза и возвращать один строковый срез. После того как мы реализуем функцию longest, код в Листинге 10-19 должен вывести The longest string is abcd.

Filename: 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}");
}
Listing 10-19: Функция main, которая вызывает функцию longest, чтобы найти более длинную из двух строковых срезов

Обратите внимание, что мы хотим, чтобы функция принимала строковые срезы, которые являются ссылками, а не строки, потому что мы не хотим, чтобы функция longest принимала владение своими параметрами. Обратитесь к «Строковые срезы как параметры» в главе 4 для более подробного обсуждения того, почему параметры, которые мы используем в Листинге 10-19, — это именно те, которые нам нужны.

Если мы попытаемся реализовать функцию longest, как показано в Листинге 10-20, она не скомпилируется.

Filename: 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 }
}
Listing 10-20: Реализация функции longest, которая возвращает более длинную из двух строковых срезов, но пока не компилируется

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

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

Filename: 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 }
}
Listing 10-21: Определение функции longest, указывающее, что все ссылки в сигнатуре должны иметь одинаковое время жизни 'a

Этот код должен скомпилироваться и дать желаемый результат, когда мы используем его с функцией 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 — простой пример.

Filename: 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 }
}
Listing 10-22: Использование функции longest со ссылками на значения String, которые имеют разные конкретные времена жизни

В этом примере string1 валидна до конца внешней области видимости, string2 валидна до конца внутренней области видимости, а result ссылается на нечто, что валидно до конца внутренней области видимости. Запустите этот код, и вы увидите, что проверка заимствований одобряет; он скомпилируется и выведет The longest string is long string is long.

Далее попробуем пример, который показывает, что время жизни ссылки в result должно быть меньшим временем жизни из двух аргументов. Мы переместим объявление переменной result за пределы внутренней области видимости, но оставим присваивание значения переменной result внутри области видимости с string2. Затем мы переместим println!, который использует result, за пределы внутренней области видимости, после того как внутренняя область видимости закончится. Код в Листинге 10-23 не скомпилируется.

Filename: 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 }
}
Listing 10-23: Попытка использовать result после того, как string2 вышла из области видимости

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

$ 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. Следующий код скомпилируется:

Filename: 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, которая не скомпилируется:

Filename: 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, которая хранит строковый срез.

Filename: 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,
    };
}
Listing 10-24: Структура, которая хранит ссылку, требующую аннотации времени жизни

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

Функция main здесь создаёт экземпляр структуры ImportantExcerpt, который хранит ссылку на первое предложение String, владеемое переменной novel. Данные в novel существуют до того, как создаётся экземпляр ImportantExcerpt. Кроме того, novel не выходит из области видимости до тех пор, пока ImportantExcerpt не выйдет из области видимости, поэтому ссылка в экземпляре ImportantExcerpt валидна.

Упрощение времени жизни (Lifetime Elision)

Вы узнали, что каждая ссылка имеет время жизни и что вам нужно указывать параметры времени жизни для функций или структур, которые используют ссылки. Однако у нас была функция в Листинге 4-9, показанная снова в Листинге 10-25, которая компилировалась без аннотаций времени жизни.

Filename: 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);
}
Listing 10-25: Функция, которую мы определили в Листинге 4-9, которая компилировалась без аннотаций времени жизни, даже though параметр и возвращаемый тип являются ссылками

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

fn first_word<'a>(s: &'a str) -> &'a str {

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

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

Шаблоны, запрограммированные в анализ Rust ссылок, называются правилами упрощения времени жизни (lifetime elision rules). Это не правила для программистов, которым нужно следовать; это набор определённых случаев, которые компилятор будет рассматривать, и если ваш код соответствует этим случаям, вам не нужно писать время жизни явно.

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

Время жизни параметров функции или метода называется входным временем жизни (input lifetimes), а время жизни возвращаемых значений — выходным временем жизни (output lifetimes).

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

Первое правило заключается в том, что компилятор назначает разный параметр времени жизни каждому времени жизни в каждом входном типе. Ссылки вроде &'_ i32 нуждаются в параметре времени жизни, и структуры вроде ImportantExcerpt<'_> нуждаются в параметре времени жизни. Например:

  • Функция fn foo(x: &i32) получит один параметр времени жизни и станет fn foo<'a>(x: &'a i32).
  • Функция fn foo(x: &i32, y: &i32) получит два параметра времени жизни и станет fn foo<'a, 'b>(x: &'a i32, y: &'b i32).
  • Функция fn foo(x: &ImportantExcerpt) получит два параметра времени жизни и станет fn foo<'a, 'b>(x: &'a ImportantExcerpt<'b>).

Второе правило: если есть ровно один параметр входного времени жизни, это время жизни назначается всем параметрам выходного времени жизни: 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: компилятор прошёл через правила упрощения времени жизни, но всё ещё не смог определить все времена жизни ссылок в сигнатуре.

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

Аннотации времени жизни в определениях методов

Когда мы реализуем методы для структуры со временем жизни, мы используем тот же синтаксис, что и для параметров обобщённого типа, как показано в Листинге 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 идут в одном списке внутри угловых скобок после имени функции.

Резюме

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

Считайте ли вы или нет, есть ещё многое, что можно узнать на темах, которые мы обсудили в этой главе: Глава 18 обсуждает объекты типажа, которые — ещё один способ использовать типажи. Есть также более сложные сценарии с аннотациями времени жизни, которые вам понадобятся только в очень продвинутых сценариях; для них вы должны прочитать Справку Rust. Но next вы узнаете, как писать тесты в Rust, чтобы убедиться, что ваш код работает так, как должен.

Инвентаризация владения #3

Инвентаризация владения — это серия викторин, проверяющих ваше понимание владения в реальных сценариях. Эти сценарии вдохновлены распространёнными вопросами о Rust с StackOverflow.

Написание автоматических тестов

В своём эссе 1972 года «Скромный программист» Эдсгер Дейкстра сказал, что «тестирование программ может быть очень эффективным способом обнаружить наличие ошибок, но оно безнадёжно недостаточно для доказательства их отсутствия». Это не значит, что нам не следует стараться тестировать как можно больше!

Корректность наших программ — это степень, в которой наш код делает то, что мы от него ожидаем. 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 собирает бинарный файл тестового раннера, который выполняет аннотированные функции и сообщает, прошла каждая тестовая функция или нет.

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

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

Давайте создадим новый библиотечный проект с именем adder, который будет складывать два числа:

$ cargo new adder --lib
     Created library `adder` project
$ cd adder

Содержимое файла src/lib.rs в вашей библиотеке adder должно выглядеть как Листинг 11-1.

Filename: 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);
    }
}
Listing 11-1: Код, автоматически сгенерированный cargo new

Файл начинается с примера функции add, чтобы у нас было что тестировать.

Пока сосредоточимся только на функции it_works. Обратите внимание на аннотацию #[test]: этот атрибут указывает, что это тестовая функция, поэтому тестовый раннер знает, что нужно рассматривать эту функцию как тест. У нас могут быть и другие, нетекстовые функции в модуле tests для помощи в подготовке общих сценариев или выполнении общих операций, поэтому нам всегда нужно указывать, какие функции являются тестами.

Тело примера функции использует макрос assert_eq! для проверки, что result, содержащий результат вызова add с аргументами 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 (target/debug/deps/adder-01ad14159ff659ab)

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

Listing 11-2: Вывод при запуске автоматически сгенерированного теста

Cargo скомпилировал и запустил тест. Мы видим строку running 1 test. Следующая строка показывает имя сгенерированной тестовой функции, tests::it_works, и что результат её выполнения — ok. Общий итог test result: ok. означает, что все тесты прошли, а часть 1 passed; 0 failed суммирует количество прошедших и проваленных тестов.

Можно пометить тест как игнорируемый, чтобы он не запускался в конкретном случае; мы рассмотрим это в разделе «Игнорирование некоторых тестов, если они не запрошены явно» позже в этой главе. Поскольку мы этого не делали здесь, в итоге показано 0 ignored. Мы также можем передать аргумент команде cargo test для запуска только тех тестов, имя которых совпадает с заданной строкой; это называется фильтрацией, и мы рассмотрим это в разделе «Запуск подмножества тестов по имени». Здесь мы не фильтровали запускаемые тесты, поэтому в конце сводки показано 0 filtered out.

Статистика 0 measured предназначена для бенчмарк-тестов, которые измеряют производительность. Бенчмарк-тесты, на момент написания, доступны только в nightly-версии Rust. Ознакомьтесь с документацией о бенчмарк-тестах, чтобы узнать больше.

Мы можем передать аргумент команде cargo test для запуска только тех тестов, имя которых совпадает с заданной строкой; это называется фильтрацией, и мы рассмотрим это в разделе «Запуск подмножества тестов по имени». Здесь мы не фильтровали запускаемые тесты, поэтому в конце сводки показано 0 filtered out.

Следующая часть вывода теста, начиная с Doc-tests adder, предназначена для результатов любых тестов документации. У нас пока нет тестов документации, но Rust может компилировать любые примеры кода, которые появляются в нашей API-документации. Эта функция помогает поддерживать документацию и код в синхронизации! Мы обсудим, как писать тесты документации, в разделе «Комментарии документации как тесты» Главы 14. Пока мы проигнорируем вывод Doc-tests.

Давайте начнём настраивать тест под свои нужды. Сначала изменим имя функции it_works на другое, например, exploration, вот так:

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

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exploration() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

Затем снова запустите cargo test. Вывод теперь показывает exploration вместо it_works:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.59s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::exploration ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Теперь добавим ещё один тест, но на этот раз сделаем тест, который не пройдёт! Тесты не проходят, когда что-то в теле тестовой функции паникует. Каждый тест запускается в новом потоке, и когда главный поток видит, что поток теста умер, тест помечается как проваленный. В Главе 9 мы говорили, что самый простый способ вызвать панику — использовать макрос panic!. Введите новый тест как функцию с именем another, чтобы ваш файл src/lib.rs выглядел как Листинг 11-3.

Filename: src/lib.rs
pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exploration() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}
Listing 11-3: Добавление второго теста, который не пройдёт, потому что мы вызываем макрос panic!

Запустите тесты снова с помощью cargo test. Вывод должен выглядеть как Листинг 11-4, который показывает, что наш тест exploration прошёл, а another — нет.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.72s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok

failures:

---- tests::another stdout ----

thread 'tests::another' panicked at src/lib.rs:17:9:
Make this test fail
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::another

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`
Listing 11-4: Результаты тестов, когда один тест прошёл, а другой — нет

Вместо ok строка test tests::another показывает FAILED. Между индивидуальными результатами и сводкой появляются два новых раздела: первый отображает подробную причину каждого сбоя теста. В этом случае мы получаем подробности, что another не прошёл, потому что он panicked at 'Make this test fail' в строке 17 файла src/lib.rs. Следующий раздел содержит только имена всех неудачных тестов, что полезно, когда тестов много и много подробного вывода о неудачных тестах. Мы можем использовать имя неудачного теста, чтобы запустить только этот тест и легче его отладить; мы поговорим больше о способах запуска тестов в разделе «Управление запуском тестов».

Итоговая строка отображается в конце: в целом, результат нашего теста — FAILED. У нас был один прошедший тест и один проваленный.

Теперь, когда вы видели, как выглядят результаты тестов в разных сценариях, давайте рассмотрим некоторые макросы, кроме panic!, которые полезны в тестах.

Проверка результатов с помощью макроса assert!

Макрос assert!, предоставляемый стандартной библиотекой, полезен, когда вы хотите убедиться, что некоторое условие в тесте вычисляется как true. Мы передаём макросу assert! аргумент, который вычисляется как логическое значение. Если значение true, ничего не происходит и тест проходит. Если значение false, макрос assert! вызывает panic!, чтобы тест не прошёл. Использование макроса assert! помогает нам проверять, что наш код работает так, как мы задумали.

В Главе 5, Листинг 5-15, мы использовали структуру Rectangle и метод can_hold, которые повторяются здесь в Листинге 11-5. Давайте поместим этот код в файл src/lib.rs, а затем напишем для него тесты, используя макрос assert!.

Filename: src/lib.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}
Listing 11-5: Структура Rectangle и её метод can_hold из Главы 5

Метод can_hold возвращает логическое значение, что делает его идеальным случаем использования для макроса assert!. В Листинге 11-6 мы пишем тест, который проверяет метод can_hold, создавая экземпляр Rectangle с шириной 8 и высотой 7 и проверяя, что он может вместить другой экземпляр Rectangle с шириной 5 и высотой 1.

Filename: src/lib.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }
}
Listing 11-6: Тест для can_hold, который проверяет, может ли больший прямоугольник действительно вместить меньший прямоугольник

Обратите внимание на строку use super::*; внутри модуля tests. Модуль tests — это обычный модуль, который следует обычным правилам видимости, которые мы рассмотрели в Главе 7 в разделе «Пути для ссылки на элемент в дереве модулей». Поскольку модуль tests — это внутренний модуль, нам нужно сделать код, который тестируем во внешнем модуле, доступным в области видимости внутреннего модуля. Мы используем здесь glob-импорт, поэтому всё, что мы определили во внешнем модуле, доступно этому модулю tests.

Мы назвали наш тест larger_can_hold_smaller, и мы создали два экземпляра Rectangle, которые нам нужны. Затем мы вызвали макрос assert! и передали ему результат вызова larger.can_hold(&smaller). Это выражение должно вернуть true, поэтому наш тест должен пройти. Давайте проверим!

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 1 test
test tests::larger_can_hold_smaller ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Он прошёл! Давайте добавим ещё один тест, на этот раз проверяя, что меньший прямоугольник не может вместить больший прямоугольник:

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

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        // --snip--
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

Поскольку правильный результат функции can_hold в этом случае — false, нам нужно инвертировать этот результат перед передачей его макросу assert!. В результате наш тест пройдёт, если can_hold вернёт false:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... ok
test tests::smaller_cannot_hold_larger ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Два теста, которые прошли! Теперь посмотрим, что произойдёт с результатами наших тестов, когда мы внесём ошибку в наш код. Мы изменим реализацию метода can_hold, заменив знак больше-than на знак меньше-than при сравнении ширин:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

// --snip--
impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width < other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

Запуск тестов теперь produces:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... FAILED
test tests::smaller_cannot_hold_larger ... ok

failures:

---- tests::larger_can_hold_smaller stdout ----

thread 'tests::larger_can_hold_smaller' panicked at src/lib.rs:28:9:
assertion failed: larger.can_hold(&smaller)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::larger_can_hold_smaller

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Наши тесты обнаружили ошибку! Поскольку larger.width равен 8, а smaller.width равен 5, сравнение ширин в can_hold теперь возвращает false: 8 не меньше 5.

Проверка равенства с помощью макросов assert_eq! и assert_ne!

Распространённый способ проверки функциональности — тестировать равенство между результатом проверяемого кода и значением, которое мы ожидаем, что код вернёт. Это можно сделать, используя макрос assert! и передав ему выражение с оператором ==. Однако это настолько распространённый тест, что стандартная библиотека предоставляет пару макросов — assert_eq! и assert_ne! — для выполнения этой проверки более удобно. Эти макросы сравнивают два аргумента на равенство или неравенство соответственно. Они также выводят оба значения, если проверка не проходит, что облегчает понимание, почему тест не прошёл; в противоположность этому, макрос assert! только указывает, что он получил значение false для выражения ==, не выводя значения, которые привели к false.

В Листинге 11-7 мы пишем функцию с именем add_two, которая прибавляет 2 к своему параметру, а затем тестируем эту функцию с помощью макроса assert_eq!.

Filename: src/lib.rs
pub fn add_two(a: usize) -> usize {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }
}
Listing 11-7: Тестирование функции add_two с помощью макроса assert_eq!

Давайте проверим, что он проходит!

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... 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

Мы создаём переменную с именем result, которая содержит результат вызова add_two(2). Затем мы передаём result и 4 в качестве аргументов в assert_eq!. Строка вывода для этого теста — test tests::it_adds_two ... ok, и текст ok указывает, что наш тест прошёл!

Давайте внесём ошибку в наш код, чтобы увидеть, как выглядит assert_eq!, когда он не проходит. Изменим реализацию функции add_two так, чтобы она вместо этого прибавляла 3:

pub fn add_two(a: usize) -> usize {
    a + 3
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }
}

Запустите тесты снова:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... FAILED

failures:

---- tests::it_adds_two stdout ----

thread 'tests::it_adds_two' panicked at src/lib.rs:12:9:
assertion `left == right` failed
  left: 5
 right: 4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::it_adds_two

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Наши тесты обнаружили ошибку! Тест it_adds_two не прошёл, и сообщение сообщает нам, что проверка, которая не прошла, — это assertion `left == right` failed и каковы значения left и right. Это сообщение помогает нам начать отладку: аргумент left, где у нас был результат вызова add_two(2), был 5, а аргумент right был 4. Вы можете представить, что это будет особенно полезно, когда у нас много тестов.

Обратите внимание, что в некоторых языках и тестовых фреймворках параметры функций проверки равенства называются expected и actual, и порядок, в котором мы указываем аргументы, имеет значение. Однако в Rust они называются left и right, и порядок, в котором мы указываем ожидаемое значение и значение, которое производит код, не имеет значения. Мы могли бы написать эту проверку как assert_eq!(add_two(2), result), что дало бы то же самое сообщение о сбое, отображающее assertion failed: `(left == right)`.

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

Под капотом макросы assert_eq! и assert_ne! используют операторы == и != соответственно. Когда проверки не проходят, эти макросы выводят свои аргументы, используя форматирование отладки, что означает, что сравниваемые значения должны реализовывать типажи PartialEq и Debug. Все примитивные типы и большинство типов стандартной библиотеки реализуют эти типажи. Для структур и перечислений, которые вы определяете сами, вам нужно реализовать PartialEq, чтобы проверять равенство этих типов. Вам также нужно реализовать Debug, чтобы выводить значения, когда проверка не проходит. Поскольку оба типажа являются выводимыми типажами, как упомянуто в Листинге 5-12 в Главе 5, это обычно так просто, как добавление аннотации #[derive(PartialEq, Debug)] к определению вашей структуры или перечисления. См. Приложение C, «Выводимые типажи», для получения более подробной информации об этих и других выводимых типажах.

Добавление пользовательских сообщений о сбое

Вы также можете добавить пользовательское сообщение для вывода вместе с сообщением о сбое в качестве необязательных аргументов макросам assert!, assert_eq! и assert_ne!. Любые аргументы, указанные после обязательных, передаются макросу format! (обсуждаемому в «Конкатенация с помощью оператора + или макроса format!» в Главе 8), поэтому вы можете передать строку формата, содержащую заполнители {} и значения для этих заполнителей. Пользовательские сообщения полезны для документирования смысла проверки; когда тест не проходит, у вас будет лучшее представление о проблеме с кодом.

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

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

pub fn greeting(name: &str) -> String {
    format!("Hello {name}!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

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

Теперь давайте внесём ошибку в этот код, изменив greeting так, чтобы он не включал name, чтобы увидеть, как выглядит стандартный сбой теста:

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

Запуск этого теста produces:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----

thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
assertion failed: result.contains("Carol")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Этот результат просто указывает, что проверка не прошла и на какой строке она находится. Более полезным сообщением о сбое было бы вывести значение из функции greeting. Давайте добавим пользовательское сообщение о сбое, состоящее из строки формата с заполнителем, заполненным фактическим значением, которое мы получили из функции greeting:

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(
            result.contains("Carol"),
            "Greeting did not contain name, value was `{result}`"
        );
    }
}

Теперь, когда мы запустим тест, мы получим более информативное сообщение об ошибке:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.93s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----

thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
Greeting did not contain name, value was `Hello!`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

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

Проверка паники с помощью should_panic

Помимо проверки возвращаемых значений, важно проверять, что наш код обрабатывает условия ошибок так, как мы ожидаем. Например, рассмотрим тип Guess, который мы создали в Главе 9, Листинг 9-13. Другой код, использующий Guess, зависит от гарантии, что экземпляры Guess будут содержать только значения между 1 и 100. Мы можем написать тест, который убедится, что попытка создать экземпляр Guess со значением вне этого диапазона вызывает панику.

Мы делаем это, добавляя атрибут should_panic к нашей тестовой функции. Тест проходит, если код внутри функции паникует; тест не проходит, если код внутри функции не паникует.

Листинг 11-8 показывает тест, который проверяет, что условия ошибок Guess::new происходят, когда мы ожидаем.

Filename: src/lib.rs
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}
Listing 11-8: Тестирование того, что условие вызовет panic!

Мы размещаем атрибут #[should_panic] после атрибута #[test] и перед тестовой функцией, к которой он применяется. Давайте посмотрим на результат, когда этот тест проходит:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests guessing_game

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Выглядит хорошо! Теперь давайте внесём ошибку в наш код, убрав условие, что функция new вызовет панику, если значение больше 100:

pub struct Guess {
    value: i32,
}

// --snip--
impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

Когда мы запускаем тест из Листинга 11-8, он не пройдёт:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----
note: test did not panic as expected

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Мы не получаем очень полезное сообщение в этом случае, но когда мы смотрим на тестовую функцию, мы видим, что она аннотирована #[should_panic]. Полученный сбой означает, что код в тестовой функции не вызвал панику.

Тесты, которые используют should_panic, могут быть неточными. Тест should_panic пройдёт, даже если тест паникует по другой причине, отличной от той, которую мы ожидали. Чтобы сделать тесты should_panic более точными, мы можем добавить необязательный параметр expected к атрибуту should_panic. Тестовый раннер убедится, что сообщение о сбое содержит предоставленный текст. Например, рассмотрим изменённый код для Guess в Листинге 11-9, где функция new паникует с разными сообщениями в зависимости от того, значение слишком мало или слишком велико.

Filename: src/lib.rs
pub struct Guess {
    value: i32,
}

// --snip--

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be greater than or equal to 1, got {value}."
            );
        } else if value > 100 {
            panic!(
                "Guess value must be less than or equal to 100, got {value}."
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}
Listing 11-9: Тестирование panic! с сообщением о панике, содержащим указанную подстроку

Этот тест пройдёт, потому что значение, которое мы поместили в параметр expected атрибута should_panic, является подстрокой сообщения, с которым паникует функция Guess::new. Мы могли бы указать всё сообщение о панике, которое ожидаем, что в этом случае было бы Guess value must be less than or equal to 100, got 200. Что вы выберете для указания, зависит от того, какая часть сообщения о панике уникальна или динамична и насколько точным вы хотите сделать свой тест. В этом случае подстроки сообщения о панике достаточно, чтобы убедиться, что код в тестовой функции выполняет случай else if value > 100.

Чтобы увидеть, что происходит, когда тест should_panic с сообщением expected не проходит, давайте снова внесём ошибку в наш код, поменяв местами тела блоков if value < 1 и else if value > 100:

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be less than or equal to 100, got {value}."
            );
        } else if value > 100 {
            panic!(
                "Guess value must be greater than or equal to 1, got {value}."
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

На этот раз, когда мы запускаем тест should_panic, он не пройдёт:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----

thread 'tests::greater_than_100' panicked at src/lib.rs:12:13:
Guess value must be greater than or equal to 1, got 200.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
      panic message: `"Guess value must be greater than or equal to 1, got 200."`,
 expected substring: `"less than or equal to 100"`

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Сообщение о сбое указывает, что этот тест действительно паниковал, как мы и ожидали, но сообщение о панике не включало ожидаемую строку less than or equal to 100. Сообщение о панике, которое мы на самом деле получили в этом случае, было Guess value must be greater than or equal to 1, got 200. Теперь мы можем начать выяснять, где наша ошибка!

Использование Result<T, E> в тестах

Все наши тесты до сих пор паникуют, когда не проходят. Мы также можем писать тесты, которые используют Result<T, E>! Вот тест из Листинга 11-1, переписанный для использования Result<T, E> и возврата Err вместо паники:

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() -> Result<(), String> {
        let result = add(2, 2);

        if result == 4 {
            Ok(())
        } else {
            Err(String::from("two plus two does not equal four"))
        }
    }
}

Функция it_works теперь имеет тип возврата Result<(), String>. В теле функции, вместо вызова макроса assert_eq!, мы возвращаем Ok(()), когда тест проходит, и Err с String внутри, когда тест не проходит.

Написание тестов так, чтобы они возвращали Result<T, E>, позволяет вам использовать оператор вопроса в теле тестов, что может быть удобным способом писать тесты, которые должны не пройти, если любая операция внутри них возвращает вариант Err.

Вы не можете использовать аннотацию #[should_panic] на тестах, которые используют Result<T, E>. Чтобы проверить, что операция возвращает вариант Err, не используйте оператор вопроса на значении Result<T, E>. Вместо этого используйте assert!(value.is_err()).

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

Управление выполнением тестов

Так же как cargo run компилирует ваш код и затем запускает полученный бинарник, cargo test компилирует код в режиме тестирования и запускает полученный тестовый бинарник. По умолчанию бинарник, созданный cargo test, выполняет все тесты параллельно и перехватывает вывод, генерируемый во время выполнения тестов, не позволяя ему отображаться. Это облегчает чтение вывода, связанного с результатами тестов. Однако вы можете указать параметры командной строки, чтобы изменить это поведение.

Некоторые параметры командной строки передаются cargo test, а некоторые — полученному тестовому бинарнику. Чтобы разделить эти два типа аргументов, укажите аргументы для cargo test, затем разделитель --, а после — аргументы для тестового бинарника. Запуск cargo test --help отображает опции, которые можно использовать с cargo test, а cargo test -- --help — опции после разделителя. Эти опции также документированы в разделе “Tests” книги rustc.

Параллельное или последовательное выполнение тестов

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

Например, предположим, что каждый из ваших тестов выполняет код, который создаёт на диске файл с именем test-output.txt и записывает в него некоторые данные. Затем каждый тест читает данные из этого файла и проверяет, что файл содержит определённое значение, которое различается в каждом тесте. Поскольку тесты выполняются одновременно, один тест может перезаписать файл в промежутке между записью и чтением файла другим тестом. Второй тест тогда завершится с ошибкой, не потому что код некорректен, а потому что тесты вмешались друг в друга при параллельном выполнении. Одно решение — убедиться, что каждый тест записывает в разные файлы; другое решение — запускать тесты по одному.

Если вы не хотите запускать тесты параллельно или хотите более тонко контролировать количество используемых потоков, вы можете передать тестовому бинарнику флаг --test-threads и количество потоков, которое хотите использовать. Посмотрите на следующий пример:

$ cargo test -- --test-threads=1

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

Отображение вывода функции

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

Например, в Листинге 11-10 показана несерьёзная функция, которая выводит значение своего параметра и возвращает 10, а также тест, который проходит, и тест, который завершается с ошибкой.

Filename: src/lib.rs
fn prints_and_returns_10(a: i32) -> i32 {
    println!("I got the value {a}");
    10
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn this_test_will_pass() {
        let value = prints_and_returns_10(4);
        assert_eq!(value, 10);
    }

    #[test]
    fn this_test_will_fail() {
        let value = prints_and_returns_10(8);
        assert_eq!(value, 5);
    }
}
Listing 11-10: Тесты для функции, вызывающей println!

Когда мы запускаем эти тесты с помощью cargo test, мы увидим следующий вывод:

$ cargo test
   Compiling silly-function v0.1.0 (file:///projects/silly-function)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)

running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok

failures:

---- tests::this_test_will_fail stdout ----
I got the value 8

thread 'tests::this_test_will_fail' panicked at src/lib.rs:19:9:
assertion `left == right` failed
  left: 10
 right: 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Обратите внимание, что нигде в этом выводе мы не видим I got the value 4, который выводится при выполнении прошедшего теста. Этот вывод был перехвачен. Вывод из теста, завершившегося с ошибкой, I got the value 8, появляется в разделе итогового вывода теста, который также показывает причину сбоя теста.

Если мы хотим видеть выводимые значения для прошедших тестов, мы можем указать Rust также показывать вывод успешных тестов с помощью --show-output:

$ cargo test -- --show-output

Когда мы снова запускаем тесты из Листинга 11-10 с флагом --show-output, мы видим следующий вывод:

$ cargo test -- --show-output
   Compiling silly-function v0.1.0 (file:///projects/silly-function)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)

running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok

successes:

---- tests::this_test_will_pass stdout ----
I got the value 4


successes:
    tests::this_test_will_pass

failures:

---- tests::this_test_will_fail stdout ----
I got the value 8

thread 'tests::this_test_will_fail' panicked at src/lib.rs:19:9:
assertion `left == right` failed
  left: 10
 right: 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Запуск подмножества тестов по имени

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

Чтобы продемонстрировать, как запустить подмножество тестов, мы сначала создадим три теста для нашей функции add_two, как показано в Листинге 11-11, и выберем, какие из них запустить.

Filename: src/lib.rs
pub fn add_two(a: usize) -> usize {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn add_two_and_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }

    #[test]
    fn add_three_and_two() {
        let result = add_two(3);
        assert_eq!(result, 5);
    }

    #[test]
    fn one_hundred() {
        let result = add_two(100);
        assert_eq!(result, 102);
    }
}
Listing 11-11: Три теста с тремя разными именами

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

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 3 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok
test tests::one_hundred ... ok

test result: ok. 3 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 test, чтобы запустить только этот тест:

$ cargo test one_hundred
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.69s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::one_hundred ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s

Выполнился только тест с именем one_hundred; два других теста не соответствовали этому имени. В выводе теста сообщается, что у нас было больше тестов, которые не запустились, отображая 2 filtered out в конце.

Мы не можем указать имена нескольких тестов таким образом; будет использоваться только первое значение, переданное cargo test. Но есть способ запустить несколько тестов.

Фильтрация для запуска нескольких тестов

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

$ cargo test add
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

Эта команда запустила все тесты с add в имени и отфильтровала тест с именем one_hundred. Также обратите внимание, что модуль, в котором находится тест, становится частью имени теста, поэтому мы можем запустить все тесты в модуле, отфильтровав по имени модуля.

Игнорирование некоторых тестов, если они явно не запрошены

Иногда несколько конкретных тестов могут быть очень трудоёмкими для выполнения, поэтому вы можете захотеть исключить их при большинстве запусков cargo test. Вместо того чтобы перечислять в качестве аргументов все тесты, которые вы хотите запустить, вы можете вместо этого пометить трудоёмкие тесты с помощью атрибута ignore, чтобы исключить их, как показано здесь:

Имя файла: 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]
    #[ignore]
    fn expensive_test() {
        // code that takes an hour to run
    }
}

После #[test] мы добавляем строку #[ignore] для теста, который хотим исключить. Теперь при запуске наших тестов it_works выполняется, а expensive_test — нет:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::expensive_test ... ignored
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 1 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

Функция expensive_test указана как ignored. Если мы хотим запустить только игнорируемые тесты, мы можем использовать cargo test -- --ignored:

$ cargo test -- --ignored
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test expensive_test ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 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 test будут получены быстро. Когда вы достигнете точки, где имеет смысл проверить результаты ignored тестов, и у вас есть время дождаться результатов, вы можете вместо этого запустить cargo test -- --ignored. Если вы хотите запустить все тесты, независимо от того, игнорируются они или нет, вы можете выполнить cargo test -- --include-ignored.

Организация тестов

Как упоминалось в начале главы, тестирование — сложная дисциплина, и разные люди используют разную терминологию и организацию. Сообщество Rust рассматривает тесты в рамках двух основных категорий: модульные тесты и интеграционные тесты. Модульные тесты (unit tests) небольшие и более сфокусированные, они тестируют один модуль изоляционно и могут проверять приватные интерфейсы. Интеграционные тесты (integration tests) полностью внешние по отношению к вашей библиотеке и используют ваш код так же, как и любой другой внешний код, используя только публичный интерфейс и потенциально охватывая несколько модулей за один тест.

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

Модульные тесты

Цель модульных тестов — протестировать каждую единицу кода изолированно от остального кода, чтобы быстро определить, где код работает, а где — нет. Вы размещаете модульные тесты в директории src в каждом файле с кодом, который они тестируют. Соглашение — создавать модуль с именем tests в каждом файле для содержания тестовых функций и аннотировать модуль cfg(test).

Модуль tests и #[cfg(test)]

Аннотация #[cfg(test)] на модуле tests говорит Rust компилировать и запускать тестовый код только при выполнении cargo test, а не при cargo build. Это экономит время компиляции, когда вы хотите только собрать библиотеку, и экономит место в итоговом скомпилированном артефакте, так как тесты не включаются. Вы увидите, что поскольку интеграционные тесты находятся в другой директории, им не нужна аннотация #[cfg(test)]. Однако, поскольку модульные тесты находятся в тех же файлах, что и код, вы будете использовать #[cfg(test)] чтобы указать, что они не должны включаться в скомпилированный результат.

Напомним, что когда мы создали новый проект adder в первом разделе этой главы, Cargo сгенерировал для нас этот код:

Имя файла: 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);
    }
}

На автоматически сгенерированном модуле tests атрибут cfg означает конфигурацию (configuration) и говорит Rust, что следующий элемент должен включаться только при наличии определённого параметра конфигурации. В этом случае параметр конфигурации — test, который предоставляется Rust для компиляции и запуска тестов. Используя атрибут cfg, Cargo компилирует наш тестовый код только если мы активно запускаем тесты через cargo test. Это включает любые вспомогательные функции, которые могут находиться внутри этого модуля, в дополнение к функциям, аннотированным #[test].

Тестирование приватных функций

В сообществе тестирования ведутся споры о том, следует ли тестировать приватные функции напрямую, и другие языки затрудняют или делают невозможным тестирование приватных функций. Независимо от того, какую идеологию тестирования вы разделяете, правила приватности Rust позволяют тестировать приватные функции. Рассмотрим код в Листинге 11-12 с приватной функцией internal_adder.

Filename: src/lib.rs
pub fn add_two(a: usize) -> usize {
    internal_adder(a, 2)
}

fn internal_adder(left: usize, right: usize) -> usize {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn internal() {
        let result = internal_adder(2, 2);
        assert_eq!(result, 4);
    }
}
Listing 11-12: Тестирование приватной функции

Обратите внимание, что функция internal_adder не помечена как pub. Тесты — это просто код Rust, и модуль tests — это просто другой модуль. Как мы обсуждали в разделе “Пути для ссылки на элемент в дереве модулей”, элементы в дочерних модулях могут использовать элементы в своих родительских модулях. В этом тесте мы подключаем все элементы родительского модуля tests в область видимости с помощью use super::*, и тогда тест может вызвать internal_adder. Если вы не считаете, что приватные функции следует тестировать, ничто в Rust не заставит вас это делать.

Интеграционные тесты

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

Директория tests

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

Давайте создадим интеграционный тест. С кодом из Листинга 11-12 всё ещё в файле src/lib.rs, создайте директорию tests и создайте новый файл с именем tests/integration_test.rs. Ваша структура директорий должна выглядеть так:

adder
├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    └── integration_test.rs

Введите код из Листинга 11-13 в файл tests/integration_test.rs.

Filename: tests/integration_test.rs
use adder::add_two;

#[test]
fn it_adds_two() {
    let result = add_two(2);
    assert_eq!(result, 4);
}
Listing 11-13: Интеграционный тест функции в крейте adder

Каждый файл в директории tests — это отдельный крейт, поэтому нам нужно подключать нашу библиотеку в область видимости каждого тестового крейта. По этой причине мы добавляем use adder::add_two; в начало кода, что не требовалось в модульных тестах.

Нам не нужно аннотировать какой-либо код в tests/integration_test.rs с помощью #[cfg(test)]. Cargo обращается с директорией tests специально и компилирует файлы в этой директории только когда мы запускаем cargo test. Запустите cargo test сейчас:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.31s
     Running unittests src/lib.rs (target/debug/deps/adder-1082c4b063a8fbe6)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-1082c4b063a8fbe6)

running 1 test
test it_adds_two ... 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

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

Первый раздел для модульных тестов такой же, как мы видели: одна строка для каждого модульного теста (одна с именем internal, которую мы добавили в Листинге 11-12) и затем итоговая строка для модульных тестов.

Раздел интеграционных тестов начинается со строки Running tests/integration_test.rs. Далее есть строка для каждой тестовой функции в этом интеграционном тесте и итоговая строка для результатов интеграционного теста прямо перед началом раздела Doc-tests adder.

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

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

$ cargo test --test integration_test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.64s
     Running tests/integration_test.rs (target/debug/deps/integration_test-82e7799c1bc62298)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Эта команда запускает только тесты в файле tests/integration_test.rs.

Подмодули в интеграционных тестах

По мере добавления большего количества интеграционных тестов вы можете захотеть создать больше файлов в директории tests для их организации; например, вы можете группировать тестовые функции по функциональности, которую они тестируют. Как упоминалось ранее, каждый файл в директории tests компилируется как отдельный крейт, что полезно для создания отдельных областей видимости, чтобы более точно имитировать способ, которым конечные пользователи будут использовать ваш крейт. Однако это означает, что файлы в директории tests не разделяют то же поведение, что и файлы в src, как вы узнали в Главе 7 относительно того, как разделять код на модули и файлы.

Разное поведение файлов в директории tests наиболее заметно, когда у вас есть набор вспомогательных функций для использования в нескольких файлах интеграционных тестов, и вы пытаетесь следовать шагам из раздела “Разделение модулей на разные файлы” Главы 7, чтобы извлечь их в общий модуль. Например, если мы создадим tests/common.rs и поместим в него функцию с именем setup, мы можем добавить некоторый код в setup, который мы хотим вызывать из нескольких тестовых функций в нескольких файлах тестов:

Имя файла: tests/common.rs

pub fn setup() {
    // setup code specific to your library's tests would go here
}

Когда мы снова запустим тесты, мы увидим новый раздел в выводе тестов для файла common.rs, даже though этот файл не содержит тестовых функций и мы нигде не вызывали функцию setup:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.89s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/common.rs (target/debug/deps/common-92948b65e88960b4)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-92948b65e88960b4)

running 1 test
test it_adds_two ... 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

То, что common появляется в результатах тестов с отображением running 0 tests, — не то, чего мы хотели. Мы просто хотели поделиться некоторым кодом с другими файлами интеграционных тестов. Чтобы избежать появления common в выводе тестов, вместо создания tests/common.rs мы создадим tests/common/mod.rs. Структура директорий проекта теперь выглядит так:

├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    ├── common
    │   └── mod.rs
    └── integration_test.rs

Это более старое соглашение об именах, которое Rust также понимает, о котором мы упоминали в разделе “Альтернативные пути файлов” Главы 7. Название файла таким образом говорит Rust не обрабатывать модуль common как файл интеграционного теста. Когда мы перемещаем код функции setup в tests/common/mod.rs и удаляем файл tests/common.rs, раздел в выводе тестов больше не появится. Файлы в поддиректориях директории tests не компилируются как отдельные крейты и не имеют разделов в выводе тестов.

После того как мы создали tests/common/mod.rs, мы можем использовать его из любого файла интеграционных тестов как модуль. Вот пример вызова функции setup из теста it_adds_two в tests/integration_test.rs:

Имя файла: tests/integration_test.rs

use adder::add_two;

mod common;

#[test]
fn it_adds_two() {
    common::setup();

    let result = add_two(2);
    assert_eq!(result, 4);
}

Обратите внимание, что объявление mod common; такое же, как объявление модуля, которое мы продемонстрировали в Листинге 7-21. Затем, в тестовой функции, мы можем вызвать функцию common::setup().

Интеграционные тесты для бинарных крейтов

Если наш проект — бинарный крейт, который содержит только файл src/main.rs и не имеет файла src/lib.rs, мы не можем создать интеграционные тесты в директории tests и подключать функции, определённые в файле src/main.rs, в область видимости с помощью оператора use. Только библиотечные крейты экспортируют функции, которые могут использовать другие крейты; бинарные крейты предназначены для самостоятельного запуска.

Это одна из причин, почему проекты Rust, которые предоставляют бинарник, имеют простой файл src/main.rs, который вызывает логику, находящуюся в файле src/lib.rs. Используя эту структуру, интеграционные тесты могут тестировать библиотечный крейт с помощью use, чтобы сделать важную функциональность доступной. Если важная функциональность работает, небольшое количество кода в файле src/main.rs будет работать также, и это небольшое количество кода не нужно тестировать.

Итог

Функции тестирования Rust предоставляют способ указать, как должен функционировать код, чтобы убедиться, что он продолжает работать так, как вы ожидаете, даже когда вы вносите изменения. Модульные тесты проверяют разные части библиотеки по отдельности и могут тестировать приватные детали реализации. Интеграционные тесты проверяют, что многие части библиотеки работают вместе правильно, и они используют публичный API библиотеки для тестирования кода так же, как внешний код будет его использовать. Хотя система типов и правила владения Rust помогают предотвратить некоторые виды ошибок, тесты всё ещё важны для уменьшения логических ошибок, связанных с ожидаемым поведением вашего кода.

Давайте объединим знания, которые вы изучили в этой главе и в предыдущих главах, чтобы поработать над проектом!

Проект ввода-вывода: создание программы командной строки

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

Примечание: в этой главе нет викторин, так как она представляет собой пошаговое практическое руководство.

Скорость, безопасность, вывод в виде одного бинарного файла и кроссплатформенная поддержка делают Rust идеальным языком для создания инструментов командной строки. Поэтому в нашем проекте мы создадим свою версию классического инструмента поиска в командной строке grep (globally search a regular expression and print — глобальный поиск по регулярному выражению с выводом). В простейшем случае grep ищет в указанном файле заданную строку. Для этого grep принимает в качестве аргументов путь к файлу и строку. Затем он читает файл, находит в нём строки, содержащие указанную строку, и выводит эти строки.

По ходу работы мы покажем, как заставить наш инструмент командной строки использовать возможности терминала, которые используют многие другие инструменты. Мы будем считывать значение переменной окружения, чтобы позволить пользователю настроить поведение нашего инструмента. Мы также будем выводить сообщения об ошибках в поток стандартной ошибки (stderr), а не в стандартный вывод (stdout). Это позволит, например, пользователю перенаправить успешный вывод в файл, продолжая видеть сообщения об ошибках на экране.

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

Наш проект grep объединит несколько концепций, которые вы уже изучили:

  • Организация кода (Глава 7)
  • Использование векторов и строк (Глава 8)
  • Обработка ошибок (Глава 9)
  • Использование типажей и времен жизни там, где это уместно (Глава 10)
  • Написание тестов (Глава 11)

Мы также кратко познакомимся с замыканиями, итераторами и объектами типажей, которые будут подробно рассмотрены в Главе 13 и Главе 18.

Приём аргументов командной строки

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

$ cargo new minigrep
     Created binary (application) `minigrep` project
$ cd minigrep

Первая задача — заставить minigrep принимать два аргумента командной строки: путь к файлу и строку для поиска. То есть мы хотим иметь возможность запускать нашу программу с помощью cargo run, двух дефисов, указывающих, что последующие аргументы предназначены для нашей программы, а не для cargo, строки для поиска и пути к файлу, в котором нужно искать, например так:

$ cargo run -- searchstring example-filename.txt

Сейчас программа, сгенерированная cargo new, не может обрабатывать передаваемые ей аргументы. Существуют готовые библиотеки на crates.io, которые могут помочь в написании программы, принимающей аргументы командной строки, но поскольку вы только начинаете изучать эту концепцию, давайте реализуем эту возможность самостоятельно.

Чтение значений аргументов

Чтобы minigrep мог читать значения передаваемых ему аргументов командной строки, нам понадобится функция std::env::args из стандартной библиотеки Rust. Эта функция возвращает итератор по аргументам командной строки, переданным minigrep. Мы полностью разберём итераторы в Главе 13. Пока вам нужно знать только две детали об итераторах: итераторы производят серию значений, и мы можем вызвать метод collect у итератора, чтобы превратить его в коллекцию, такую как вектор, содержащую все элементы, которые производит итератор.

Код в Листинге 12-1 позволяет вашей программе minigrep читать любые переданные ей аргументы командной строки, а затем собирать значения в вектор.

Filename: src/main.rs
use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    dbg!(args);
}
Listing 12-1: Сбор аргументов командной строки в вектор и их вывод

Сначала мы подключаем модуль std::env в область видимости с помощью оператора use, чтобы использовать его функцию args. Обратите внимание, что функция std::env::args вложена в два уровня модулей. Как мы обсуждали в Главе 7, в случаях, когда нужная функция вложена более чем в один модуль, мы предпочитаем подключать в область видимости родительский модуль, а не саму функцию. Делая это, мы можем легко использовать другие функции из std::env. Это также менее двусмысленно, чем добавление use std::env::args и последующий вызов функции просто как args, потому что args можно легко принять за функцию, определённую в текущем модуле.

Функция args и невалидный Unicode

Обратите внимание, что std::env::args вызовет панику (panic), если любой аргумент содержит невалидный Unicode. Если вашей программе нужно принимать аргументы, содержащие невалидный Unicode, используйте вместо этого std::env::args_os. Эта функция возвращает итератор, производящий значения OsString вместо значений String. Мы выбрали использование std::env::args здесь для простоты, потому что значения OsString различаются в зависимости от платформы и сложнее в работе, чем значения String.

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

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

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/minigrep`
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
]
$ cargo run -- needle haystack
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.57s
     Running `target/debug/minigrep needle haystack`
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
    "needle",
    "haystack",
]

Обратите внимание, что первое значение в векторе — "target/debug/minigrep", это имя нашего бинарного файла. Это соответствует поведению списка аргументов в C, позволяя программам использовать имя, под которым они были вызваны, в своём выполнении. Часто удобно иметь доступ к имени программы на случай, если вы хотите вывести его в сообщениях или изменить поведение программы в зависимости от того, каким псевдонимом командной строки она была вызвана. Но для целей этой главы мы проигнорируем его и сохраним только два нужных нам аргумента.

Сохранение значений аргументов в переменные

Программа сейчас может получать доступ к значениям, указанным в качестве аргументов командной строки. Теперь нам нужно сохранить значения двух аргументов в переменные, чтобы мы могли использовать эти значения в остальной части программы. Мы делаем это в Листинге 12-2.

Filename: src/main.rs
use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let file_path = &args[2];

    println!("Searching for {query}");
    println!("In file {file_path}");
}
Listing 12-2: Создание переменных для хранения аргумента запроса и аргумента пути к файлу

Как мы видели, когда выводили вектор, имя программы занимает первое значение в векторе по индексу args[0], поэтому мы начинаем аргументы с индекса 1. Первый аргумент, который принимает minigrep, — это строка, которую мы ищем, поэтому мы помещаем ссылку на первый аргумент в переменную query. Второй аргумент будет путём к файлу, поэтому мы помещаем ссылку на второй аргумент в переменную file_path.

Мы временно выводим значения этих переменных, чтобы убедиться, что код работает так, как мы задумали. Давайте запустим эту программу снова с аргументами test и sample.txt:

$ cargo run -- test sample.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep test sample.txt`
Searching for test
In file sample.txt

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

Чтение файла

Теперь добавим функциональность для чтения файла, указанного в аргументе file_path. Сначала нам понадобится пример файла для тестирования: возьмём файл с небольшим количеством текста на нескольких строках, содержащий повторяющиеся слова. В листинге 12-3 представлено стихотворение Эмили Дикинсон, которое отлично подойдёт! Создайте файл с именем poem.txt в корневой папке вашего проекта и введите стихотворение «I’m Nobody! Who are you?».

Filename: poem.txt
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!
Listing 12-3: Стихотворение Эмили Дикинсон служит хорошим тестовым примером.

Когда текст будет готов, отредактируйте src/main.rs и добавьте код для чтения файла, как показано в листинге 12-4.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    // --snip--
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let file_path = &args[2];

    println!("Searching for {query}");
    println!("In file {file_path}");

    let contents = fs::read_to_string(file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}
Listing 12-4: Чтение содержимого файла, указанного вторым аргументом

Сначала мы подключаем соответствующую часть стандартной библиотеки с помощью инструкции use: нам нужен std::fs для работы с файлами.

В main новая инструкция fs::read_to_string принимает file_path, открывает этот файл и возвращает значение типа std::io::Result<String>, содержащее содержимое файла.

После этого мы снова добавляем временную инструкцию println!, которая выводит значение contents после чтения файла, чтобы проверить, работает ли программа на данном этапе.

Запустим этот код с любой строкой в качестве первого аргумента командной строки (поскольку мы ещё не реализовали часть поиска) и файлом poem.txt в качестве второго аргумента:

$ cargo run -- the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

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

Рефакторинг для улучшения модульности и обработки ошибок

Чтобы улучшить нашу программу, мы исправим четыре проблемы, связанные со структурой программы и обработкой потенциальных ошибок. Во-первых, функция main теперь выполняет две задачи: разбор аргументов и чтение файлов. По мере роста программы количество отдельных задач, которые обрабатывает main, будет увеличиваться. По мере приобретения функцией новых обязанностей её становится сложнее анализировать, тестировать и изменять, не нарушая одну из её частей. Лучше разделить функциональность так, чтобы каждая функция отвечала за одну задачу.

Эта проблема также связана со второй: хотя query и file_path являются конфигурационными переменными программы, такие переменные, как contents, используются для выполнения логики программы. Чем длиннее становится main, тем больше переменных нам нужно будет привести в область видимости; чем больше переменных у нас в области видимости, тем сложнее будет отслеживать назначение каждой. Лучше сгруппировать конфигурационные переменные в одну структуру, чтобы сделать их назначение ясным.

Третья проблема в том, что мы использовали expect для вывода сообщения об ошибке при сбое чтения файла, но сообщение об ошибке просто выводит Should have been able to read the file. Чтение файла может завершиться неудачей несколькими способами: например, файл может отсутствовать или у нас может не быть разрешения на его открытие. Сейчас, независимо от ситуации, мы бы выводили одно и то же сообщение об ошибке для всего, что не дало бы пользователю никакой информации!

Четвёртая: мы используем expect для обработки ошибки, и если пользователь запустит нашу программу, не указав достаточное количество аргументов, он получит ошибку index out of bounds от Rust, которая не объясняет проблему чётко. Было бы лучше, если бы весь код обработки ошибок был в одном месте, чтобы будущие сопровождающие имели только одно место для консультации с кодом, если логика обработки ошибок потребует изменений. Наличие всего кода обработки ошибок в одном месте также обеспечит, что мы выводим сообщения, которые будут понятны нашим конечным пользователям.

Давайте решим эти четыре проблемы, рефакторируя наш проект.

Разделение ответственности для бинарных проектов

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

  • Разделите вашу программу на файл main.rs и файл lib.rs и переместите логику программы в lib.rs.
  • Пока логика разбора командной строки мала, она может оставаться в main.rs.
  • Когда логика разбора командной строки начинает усложняться, извлеките её из main.rs и переместите в lib.rs.

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

  • Вызов логики разбора командной строки со значениями аргументов
  • Настройка любой другой конфигурации
  • Вызов функции run в lib.rs
  • Обработка ошибки, если run возвращает ошибку

Этот шаблон касается разделения ответственности: main.rs отвечает за запуск программы, а lib.rs — за всю логику решения задачи. Поскольку вы не можете тестировать функцию main напрямую, эта структура позволяет тестировать всю логику вашей программы, перемещая её в функции в lib.rs. Код, который остаётся в main.rs, будет достаточно мал, чтобы проверить его корректность чтением. Давайте переработаем нашу программу, следуя этому процессу.

Извлечение парсера аргументов

Мы извлечём функциональность для разбора аргументов в функцию, которую main вызовет для подготовки к перемещению логики разбора командной строки в src/lib.rs. Листинг 12-5 показывает новый начало main, который вызывает новую функцию parse_config, которую мы определим в src/main.rs на данный момент.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let (query, file_path) = parse_config(&args);

    // --snip--

    println!("Searching for {query}");
    println!("In file {file_path}");

    let contents = fs::read_to_string(file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

fn parse_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let file_path = &args[2];

    (query, file_path)
}
Listing 12-5: Извлечение функции parse_config из main

Мы по-прежнему собираем аргументы командной строки в вектор, но вместо присвоения значения аргумента по индексу 1 переменной query и значения аргумента по индексу 2 переменной file_path внутри функции main, мы передаём весь вектор в функцию parse_config. Затем функция parse_config содержит логику, которая определяет, какой аргумент идёт в какую переменную, и возвращает значения обратно в main. Мы по-прежнему создаём переменные query и file_path в main, но main больше не отвечает за определение соответствия между аргументами командной строки и переменными.

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

Группировка конфигурационных значений

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

Другим признаком, показывающим, что есть место для улучшения, является часть config в parse_config, что подразумевает, что два возвращаемых значения связаны и являются частью одного конфигурационного значения. В настоящее время мы не передаём это значение в структуре данных, кроме как группируя два значения в кортеж; вместо этого мы поместим два значения в одну структуру и дадим каждому полю структуры осмысленное имя. Это облегчит будущим сопровождающим этого кода понимание того, как разные значения связаны друг с другом и в чём их назначение.

Листинг 12-6 показывает улучшения функции parse_config.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = parse_config(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    // --snip--

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

fn parse_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let file_path = args[2].clone();

    Config { query, file_path }
}
Listing 12-6: Рефакторинг parse_config для возврата экземпляра структуры Config

Мы добавили структуру с именем Config, определённую с полями с именами query и file_path. Сигнатура parse_config теперь указывает, что она возвращает значение Config. В теле parse_config, где мы раньше возвращали строковые срезы, ссылающиеся на значения String в args, теперь мы определяем Config так, чтобы она содержала владеющие значения String. Переменная args в main является владельцем значений аргументов и только позволяет функции parse_config занимать их, что означает, что мы нарушим правила заимствования Rust, если Config попытается принять владение значениями в args.

Существует несколько способов управления данными String; самый простой, хотя и не самый эффективный, путь — вызвать метод clone на значениях. Это создаст полную копию данных для владения экземпляром Config, что займёт больше времени и памяти, чем хранение ссылки на строковые данные. Однако клонирование данных также делает наш код очень простым, потому что нам не нужно управлять временем жизни ссылок; в этом обстоятельстве пожертвовать небольшой производительностью ради простоты — разумный компромисс.

Компромиссы использования clone

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

Мы обновили main так, чтобы он помещал экземпляр Config, возвращаемый parse_config, в переменную с именем config, и обновили код, который ранее использовал отдельные переменные query и file_path, чтобы теперь он использовал поля структуры Config.

Теперь наш код более ясно передаёт, что query и file_path связаны и что их назначение — настроить, как будет работать программа. Любой код, который использует эти значения, знает, что нужно искать их в экземпляре config в полях с именами, соответствующими их назначению.

Создание конструктора для Config

До сих пор мы извлекли логику, ответственную за разбор аргументов командной строки из main и поместили её в функцию parse_config. Это помогло нам увидеть, что значения query и file_path связаны, и это отношение должно быть передано в нашем коде. Затем мы добавили структуру Config, чтобы назвать связанное назначение query и file_path и иметь возможность возвращать имена значений как имена полей структуры из функции parse_config.

Теперь, когда назначение функции parse_config — создать экземпляр Config, мы можем изменить parse_config из обычной функции на функцию с именем new, связанную со структурой Config. Внесение этого изменения сделает код более идиоматичным. Мы можем создавать экземпляры типов в стандартной библиотеке, таких как String, вызывая String::new. Аналогично, изменив parse_config на функцию new, связанную с Config, мы сможем создавать экземпляры Config, вызывая Config::new. Листинг 12-7 показывает изменения, которые нам нужно внести.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");

    // --snip--
}

// --snip--

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}
Listing 12-7: Изменение parse_config на Config::new

Мы обновили main там, где вызывали parse_config, чтобы вместо этого вызывать Config::new. Мы изменили имя parse_config на new и переместили его в блок impl, который связывает функцию new с Config. Попробуйте скомпилировать этот код снова, чтобы убедиться, что он работает.

Исправление обработки ошибок

Теперь мы поработаем над исправлением нашей обработки ошибок. Напомним, что попытка получить доступ к значениям в векторе args по индексу 1 или индексу 2 приведёт к панике программы, если вектор содержит менее трёх элементов. Попробуйте запустить программу без аргументов; она будет выглядеть так:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`

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

Строка index out of bounds: the len is 1 but the index is 1 — это сообщение об ошибке, предназначенное для программистов. Оно не поможет нашим конечным пользователям понять, что им следует делать вместо этого. Давайте исправим это сейчас.

Улучшение сообщения об ошибке

В Листинге 12-8 мы добавляем проверку в функцию new, которая проверит, что срез достаточно длинный, прежде чем обращаться к индексу 1 и индексу 2. Если срез недостаточно длинный, программа паникует и отображает лучшее сообщение об ошибке.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    // --snip--
    fn new(args: &[String]) -> Config {
        if args.len() < 3 {
            panic!("not enough arguments");
        }
        // --snip--

        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}
Listing 12-8: Добавление проверки количества аргументов

Этот код похож на функцию Guess::new, которую мы написали в Листинге 9-13, где мы вызвали panic!, когда аргумент value был вне диапазона допустимых значений. Вместо проверки диапазона значений здесь мы проверяем, что длина args составляет как минимум 3, и остальная часть функции может работать при условии, что это условие выполнено. Если args содержит менее трёх элементов, это условие будет true, и мы вызываем макрос panic!, чтобы немедленно завершить программу.

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

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`

thread 'main' panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Этот вывод лучше: теперь у нас есть разумное сообщение об ошибке. Однако у нас также есть лишняя информация, которую мы не хотим давать нашим пользователям. Возможно, техника, которую мы использовали в Листинге 9-13, не лучшая для использования здесь: вызов panic! более уместен для проблем программирования, чем для проблем использования, как обсуждалось в Главе 9. Вместо этого мы будем использовать другую технику, которую вы изучили в Главе 9 — возврат Result, который указывает либо на успех, либо на ошибку.

Возврат Result вместо вызова panic!

Мы можем вместо этого вернуть значение Result, которое будет содержать экземпляр Config в случае успеха и будет описывать проблему в случае ошибки. Мы также изменим имя функции с new на build, потому что многие программисты ожидают, что функции new никогда не завершаются неудачей. Когда Config::build общается с main, мы можем использовать тип Result, чтобы сигнализировать, что возникла проблема. Затем мы можем изменить main, чтобы преобразовать вариант Err в более практичную ошибку для наших пользователей без окружающего текста о thread 'main' и RUST_BACKTRACE, который вызывает вызов panic!.

Листинг 12-9 показывает изменения, которые нам нужно внести в возвращаемое значение функции, которую мы теперь называем Config::build, и тело функции, необходимое для возврата Result. Обратите внимание, что это не скомпилируется, пока мы не обновим main также, что мы сделаем в следующем листинге.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}
Listing 12-9: Возврат Result из Config::build

Наша функция build возвращает Result с экземпляром Config в случае успеха и строковым литералом в случае ошибки. Наши значения ошибок всегда будут строковыми литералами, которые имеют время жизни 'static.

Мы внесли два изменения в теле функции: вместо вызова panic!, когда пользователь не передаёт достаточно аргументов, мы теперь возвращаем значение Err, и мы обернули возвращаемое значение Config в Ok. Эти изменения заставляют функцию соответствовать её новой сигнатуре типа.

Возврат значения Err из Config::build позволяет функции main обрабатывать значение Result, возвращаемое функцией build, и выходить из процесса более чисто в случае ошибки.

Вызов Config::build и обработка ошибок

Чтобы обработать случай ошибки и вывести понятное пользователю сообщение, нам нужно обновить main для обработки Result, возвращаемого Config::build, как показано в Листинге 12-10. Мы также возьмём на себя ответственность за выход из инструмента командной строки с ненулевым кодом ошибки у panic! и вместо этого реализуем это вручную. Ненулевой статус выхода — это соглашение для сигнализации процессу, который вызвал нашу программу, что программа завершилась с состоянием ошибки.

Filename: src/main.rs
use std::env;
use std::fs;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}
Listing 12-10: Выход с кодом ошибки, если создание Config завершается неудачей

В этом листинге мы использовали метод, который ещё не рассматривали подробно: unwrap_or_else, который определён для Result<T, E> стандартной библиотекой. Использование unwrap_or_else позволяет нам определить некоторую пользовательскую обработку ошибок, не использующую panic!. Если Result является значением Ok, поведение этого метода похоже на unwrap: он возвращает внутреннее значение, которое оборачивает Ok. Однако если значение является значением Err, этот метод вызывает код в замыкании, которое является анонимной функцией, которую мы определяем и передаём в качестве аргумента в unwrap_or_else. Мы подробнее рассмотрим замыкания в Главе 13. Пока вам просто нужно знать, что unwrap_or_else передаст внутреннее значение Err, которое в этом случае является статической строкой "not enough arguments", которую мы добавили в Листинге 12-9, в наше замыкание в аргументе err, который появляется между вертикальными чертами. Код в замыкании затем может использовать значение err, когда он выполняется.

Мы добавили новую строку use, чтобыBring process из стандартной библиотеки в область видимости. Код в замыкании, который будет выполнен в случае ошибки, состоит всего из двух строк: мы выводим значение err, а затем вызываем process::exit. Функция process::exit немедленно остановит программу и вернёт число, переданное в качестве кода статуса выхода. Это похоже на обработку на основе panic!, которую мы использовали в Листинге 12-8, но мы больше не получаем весь дополнительный вывод. Давайте попробуем:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments

Отлично! Этот вывод гораздо более дружелюбен для наших пользователей.

Извлечение логики из main

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

Листинг 12-11 показывает извлечённую функцию run. Пока мы просто делаем небольшое, постепенное улучшение, извлекая функцию. Мы по-прежнему определяем функцию в src/main.rs.

Filename: src/main.rs
use std::env;
use std::fs;
use std::process;

fn main() {
    // --snip--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    run(config);
}

fn run(config: Config) {
    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

// --snip--

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}
Listing 12-11: Извлечение функции run, содержащей остальную логику программы

Функция run теперь содержит всю оставшуюся логику из main, начиная с чтения файла. Функция run принимает экземпляр Config в качестве аргумента.

Возврат ошибок из функции run

С оставшейся логикой программы, отделённой в функцию run, мы можем улучшить обработку ошибок, как мы это сделали с Config::build в Листинге 12-9. Вместо того чтобы позволить программе паниковать, вызывая expect, функция run будет возвращать Result<T, E>, когда что-то пойдёт не так. Это позволит нам дальнейшим образом консолидировать логику вокруг обработки ошибок в main в удобном для пользователя виде. Листинг 12-12 показывает изменения, которые нам нужно внести в сигнатуру и тело run.

Filename: src/main.rs
use std::env;
use std::fs;
use std::process;
use std::error::Error;

// --snip--


fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    run(config);
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}
Listing 12-12: Изменение функции run на возврат Result

Мы внесли три значительных изменения здесь. Во-первых, мы изменили возвращаемый тип функции run на Result<(), Box<dyn Error>>. Эта функция ранее возвращала тип единицы, (), и мы сохраняем его как значение, возвращаемое в случае Ok.

Для типа ошибки мы использовали объект типажа Box<dyn Error> (и мы привели std::error::Error в область видимости с помощью оператора use вверху). Мы рассмотрим объекты типажа в Главе 18. Пока просто знайте, что Box<dyn Error> означает, что функция вернёт тип, который реализует типаж Error, но нам не нужно указывать, какой конкретный тип будет у возвращаемого значения. Это даёт нам гибкость возвращать значения ошибок, которые могут быть разных типов в разных случаях ошибок. Ключевое слово dyn — сокращение от динамический.

Во-вторых, мы убрали вызов expect в пользу оператора ?, как мы говорили в Главе 9. Вместо panic! при ошибке ? вернёт значение ошибки из текущей функции для обработки вызывающим.

В-третьих, функция run теперь возвращает значение Ok в случае успеха. Мы объявили тип успеха функции run как () в сигнатуре, что означает, что нам нужно обернуть значение типа единицы в значение Ok. Этот синтаксис Ok(()) может выглядеть немного странно сначала, но использование () таким образом — идиоматический способ указать, что мы вызываем run только для её побочных эффектов; она не возвращает значение, которое нам нужно.

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

$ cargo run -- the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
  --> src/main.rs:19:5
   |
19 |     run(config);
   |     ^^^^^^^^^^^
   |
   = 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
   |
19 |     let _ = run(config);
   |     +++++++

warning: `minigrep` (bin "minigrep") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.71s
     Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

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

Обработка ошибок, возвращаемых из run в main

Мы проверим наличие ошибок и обработаем их, используя технику, похожую на ту, которую мы использовали с Config::build в Листинге 12-10, но с небольшой разницей:

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

use std::env;
use std::error::Error;
use std::fs;
use std::process;

fn main() {
    // --snip--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

Мы используем if let вместо unwrap_or_else, чтобы проверить, возвращает ли run значение Err, и вызвать process::exit(1), если это так. Функция run не возвращает значение, которое мы хотим развернуть так же, как Config::build возвращает экземпляр Config. Поскольку run возвращает () в случае успеха, нас интересует только обнаружение ошибки, поэтому нам не нужен unwrap_or_else для возврата развёрнутого значения, которое было бы только ().

Тела функций if let и unwrap_or_else одинаковы в обоих случаях: мы выводим ошибку и выходим.

Разделение кода на библиотечный крейт

Наш проект minigrep выглядит хорошо до сих пор! Теперь мы разделим файл src/main.rs и поместим некоторый код в файл src/lib.rs. Таким образом, мы сможем тестировать код и иметь файл src/main.rs с меньшим количеством обязанностей.

Давайте переместим весь код, который не находится в функции main, из src/main.rs в src/lib.rs:

  • Определение функции run
  • Соответствующие операторы use
  • Определение Config
  • Определение функции Config::build

Содержимое src/lib.rs должно иметь сигнатуры, показанные в Листинге 12-13 (мы опустили тела функций для краткости). Обратите внимание, что это не скомпилируется, пока мы не изменим src/main.rs в Листинге 12-14.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        // --snip--
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    // --snip--
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}
Listing 12-13: Перемещение Config и run в src/lib.rs

Мы широко использовали ключевое слово pub: на Config, на его полях и его методе build, и на функции run. Теперь у нас есть библиотечный крейт с публичным API, который мы можем тестировать!

Теперь нам нужно привести код, который мы переместили в src/lib.rs, в область видимости бинарного крейта в src/main.rs, как показано в Листинге 12-14.

Filename: src/main.rs
use std::env;
use std::process;

use minigrep::Config;

fn main() {
    // --snip--
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    if let Err(e) = minigrep::run(config) {
        // --snip--
        println!("Application error: {e}");
        process::exit(1);
    }
}
Listing 12-14: Использование библиотечного крейта minigrep в src/main.rs

Мы добавляем строку use minigrep::Config, чтобыBring тип Config из библиотечного крейта в область видимости бинарного крейта, и мы добавляем префикс имени крейта к функции run. Теперь вся функциональность должна быть соединена и должна работать. Запустите программу с cargo run и убедитесь, что всё работает правильно.

Фух! Это была большая работа, но мы подготовили себя к успеху в будущем. Теперь нам гораздо проще обрабатывать ошибки, и мы сделали код более модульным. Почти вся наша работа будет выполняться в src/lib.rs с этого момента.

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

Разработка функциональности библиотеки с использованием разработки через тестирование

Теперь, когда мы выделили логику в src/lib.rs и оставили сбор аргументов и обработку ошибок в src/main.rs, стало гораздо проще писать тесты для основной функциональности нашего кода. Мы можем напрямую вызывать функции с различными аргументами и проверять возвращаемые значения, не запуская наш бинарный файл из командной строки.

В этом разделе мы добавим логику поиска в программу minigrep, используя процесс разработки через тестирование (TDD) со следующими шагами:

  1. Напишите тест, который не проходит, и запустите его, чтобы убедиться, что он падает по ожидаемой причине.
  2. Напишите или измените ровно столько кода, чтобы новый тест прошёл.
  3. Рефакторинг только что добавленного или изменённого кода и убедитесь, что тесты продолжают проходить.
  4. Повторяйте с шага 1!

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

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

Написание падающего теста

Поскольку они нам больше не нужны, давайте удалим операторы println! из src/lib.rs и src/main.rs, которые мы использовали для проверки поведения программы. Затем, в src/lib.rs, мы добавим модуль tests с тестовой функцией, как мы делали в Главе 11. Тестовая функция задаёт поведение, которое мы хотим видеть у функции search: она будет принимать запрос и текст для поиска и возвращать только строки из текста, содержащие запрос. Листинг 12-15 показывает этот тест, который пока не скомпилируется.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-15: Создание падающего теста для функции search, которую мы хотели бы иметь

Этот тест ищет строку "duct". Текст, который мы ищем, состоит из трёх строк, только одна из которых содержит "duct" (обратите внимание, что обратный слеш после открывающей двойной кавычки говорит Rust не добавлять символ новой строки в начале содержимого этой строковой константы). Мы утверждаем, что значение, возвращаемое функцией search, содержит только ожидаемую строку.

Мы пока не можем запустить этот тест и увидеть, как он падает, потому что тест даже не компилируется: функции search ещё не существует! В соответствии с принципами TDD мы добавим ровно столько кода, чтобы тест скомпилировался и запустился, добавив определение функции search, которая всегда возвращает пустой вектор, как показано в Листинге 12-16. Тогда тест должен скомпилироваться и упасть, потому что пустой вектор не соответствует вектору, содержащему строку "safe, fast, productive."

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-16: Определение ровно того количества кода функции search, чтобы наш тест скомпилировался

Обратите внимание, что нам нужно определить явное время жизни 'a в сигнатуре search и использовать это время жизни с аргументом contents и возвращаемым значением. Вспомните из Главы 10, что параметры времени жизни указывают, какое время жизни аргумента связано со временем жизни возвращаемого значения. В этом случае мы указываем, что возвращаемый вектор должен содержать строковые срезы, которые ссылаются на срезы аргумента contents (а не на аргумент query).

Другими словами, мы говорим Rust, что данные, возвращаемые функцией search, будут жить столько же, сколько данные, переданные в функцию search в аргументе contents. Это важно! Данные, на которые ссылается срез, должны быть действительными, чтобы ссылка была действительной; если компилятор предположит, что мы делаем строковые срезы из query, а не из contents, он выполнит свою проверку безопасности некорректно.

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

$ cargo build
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
  --> src/lib.rs:28:51
   |
28 | pub fn search(query: &str, contents: &str) -> Vec<&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 `query` or `contents`
help: consider introducing a named lifetime parameter
   |
28 | pub fn search<'a>(query: &'a str, contents: &'a str) -> Vec<&'a str> {
   |              ++++         ++                 ++              ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `minigrep` (lib) due to 1 previous error

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

Другие языки программирования не требуют от вас связывать аргументы с возвращаемыми значениями в сигнатуре, но эта практика со временем станет проще. Вы можете захотеть сравнить этот пример с примерами в разделе “Проверка ссылок с помощью времени жизни” в Главе 10.

Теперь давайте запустим тест:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.97s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::one_result ... FAILED

failures:

---- tests::one_result stdout ----

thread 'tests::one_result' panicked at src/lib.rs:44:9:
assertion `left == right` failed
  left: ["safe, fast, productive."]
 right: []
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::one_result

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Отлично, тест падает, как мы и ожидали. Давайте заставим тест пройти!

Написание кода для прохождения теста

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

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

Давайте разберём каждый шаг, начиная с перебора строк.

Перебор строк с помощью метода lines

В Rust есть полезный метод для построчного перебора строк, удобно названный lines, который работает так, как показано в Листинге 12-17. Обратите внимание, что это пока не скомпилируется.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        // do something with line
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-17: Перебор каждой строки в contents

Метод lines возвращает итератор. Мы подробно поговорим об итераторах в Главе 13, но вспомните, что вы видели такой способ использования итератора в Листинге 3-5, где мы использовали цикл for с итератором для выполнения некоторого кода над каждым элементом коллекции.

Поиск запроса в каждой строке

Далее мы проверим, содержит ли текущая строка нашу строку запроса. К счастью, у строк есть полезный метод с именем contains, который делает это за нас! Добавьте вызов метода contains в функцию search, как показано в Листинге 12-18. Обратите внимание, что это всё ещё не скомпилируется.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        if line.contains(query) {
            // do something with line
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-18: Добавление функциональности для проверки, содержит ли строка строку в query

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

Хранение соответствующих строк

Чтобы завершить эту функцию, нам нужен способ хранить соответствующие строки, которые мы хотим вернуть. Для этого мы можем создать изменяемый вектор перед циклом for и вызвать метод push для сохранения line в векторе. После цикла for мы возвращаем вектор, как показано в Листинге 12-19.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-19: Хранение строк, которые соответствуют, чтобы мы могли их вернуть

Теперь функция search должна возвращать только строки, содержащие query, и наш тест должен пройти. Давайте запустим тест:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.22s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::one_result ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Наш тест прошёл, значит, он работает!

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

Использование функции search в функции run

Теперь, когда функция search работает и протестирована, нам нужно вызвать search из нашей функции run. Нам нужно передать значение config.query и contents, которые run читает из файла, в функцию search. Затем run выведет каждую строку, возвращаемую из search:

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

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

Мы всё ещё используем цикл for для возврата каждой строки из search и её печати.

Теперь вся программа должна работать! Давайте попробуем, сначала со словом, которое должно вернуть ровно одну строку из стихотворения Эмили Дикинсон: frog.

$ cargo run -- frog poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/minigrep frog poem.txt`
How public, like a frog

Круто! Теперь давайте попробуем слово, которое будет соответствовать нескольким строкам, например body:

$ cargo run -- body poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep body poem.txt`
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!

И наконец, давайте убедимся, что мы не получим никаких строк, когда ищем слово, которого нигде нет в стихотворении, например monomorphization:

$ cargo run -- monomorphization poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep monomorphization poem.txt`

Отлично! Мы построили свою мини-версию классического инструмента и узнали многое о том, как структурировать приложения. Мы также узнали немного о вводе-выводе файлов, времени жизни, тестировании и парсинге аргументов командной строки.

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

Работа с переменными окружения

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

Написание теста, который не проходит, для функции search без учёта регистра

Сначала мы добавляем новую функцию search_case_insensitive, которая будет вызываться, когда переменная окружения имеет значение. Мы продолжим следовать процессу TDD, поэтому первый шаг снова — написать тест, который не проходит. Мы добавим новый тест для новой функции search_case_insensitive и переименуем наш старый тест с one_result на case_sensitive, чтобы прояснить разницу между двумя тестами, как показано в Листинге 12-20.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 12-20: Добавление нового теста, который не проходит, для функции без учёта регистра, которую мы собираемся добавить

Обратите внимание, что мы также отредактировали поле contents в старом тесте. Мы добавили новую строку с текстом "Duct tape." с заглавной D, которая не должна совпадать с запросом "duct" при поиске с учётом регистра. Изменение старого теста таким образом помогает убедиться, что мы случайно не сломаем функциональность поиска с учётом регистра, которую мы уже реализовали. Этот тест должен сейчас пройти и должен продолжать проходить, пока мы работаем над поиском без учёта регистра.

Новый тест для поиска без учёта регистра использует "rUsT" в качестве запроса. В функции search_case_insensitive, которую мы собираемся добавить, запрос "rUsT" должен совпадать со строкой, содержащей "Rust:" с заглавной R, и совпадать со строкой "Trust me.", даже несмотря на то, что у обеих разный регистр по сравнению с запросом. Это наш тест, который не проходит, и он не скомпилируется, потому что мы ещё не определили функцию search_case_insensitive. Вы можете добавить каркасную реализацию, которая всегда возвращает пустой вектор, аналогично тому, как мы сделали для функции search в Листинге 12-16, чтобы увидеть, что тест компилируется и не проходит.

Реализация функции search_case_insensitive

Функция search_case_insensitive, показанная в Листинге 12-21, будет почти такой же, как функция search. Единственное отличие в том, что мы будем приводить query и каждую line к нижнему регистру, чтобы независимо от регистра входных аргументов они были в одном регистре при проверке, содержит ли строка запрос.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 12-21: Определение функции search_case_insensitive для приведения запроса и строки к нижнему регистру перед их сравнением

Сначала мы приводим строку query к нижнему регистру и сохраняем её в новой переменной с тем же именем, скрывая исходный query. Вызов to_lowercase для запроса необходим, чтобы независимо от того, является ли запрос пользователя "rust", "RUST", "Rust" или "rUsT", мы рассматривали запрос как "rust" и были нечувствительны к регистру. Хотя to_lowercase обработает базовый Unicode, она не будет на 100% точной. Если бы мы писали реальное приложение, мы хотели бы сделать здесь немного больше работы, но этот раздел о переменных окружения, а не о Unicode, поэтому мы оставим это здесь.

Обратите внимание, что query теперь является String, а не строковым срезом, потому что вызов to_lowercase создаёт новые данные, а не ссылается на существующие. Допустим, запрос — "rUsT", как в примере: этот строковый срез не содержит строчную u или t для использования, поэтому нам нужно выделить новую String, содержащую "rust". Когда мы передаём query в качестве аргумента методу contains сейчас, нам нужно добавить амперсанд, потому что сигнатура contains определена как принимающая строковый срез.

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

Давайте посмотрим, проходит ли эта реализация тесты:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.33s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 2 tests
test tests::case_insensitive ... ok
test tests::case_sensitive ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Отлично! Они прошли. Теперь давайте вызовем новую функцию search_case_insensitive из функции run. Сначала мы добавим параметр конфигурации в структуру Config, чтобы переключаться между поиском с учётом и без учёта регистра. Добавление этого поля вызовет ошибки компиляции, потому что мы нигде не инициализируем это поле:

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

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

Мы добавили поле ignore_case, которое содержит логическое значение. Далее нам нужно, чтобы функция run проверяла значение поля ignore_case и использовала его для принятия решения о вызове функции search или функции search_case_insensitive, как показано в Листинге 12-22. Это всё ещё не скомпилируется.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 12-22: Вызов либо search, либо search_case_insensitive в зависимости от значения в config.ignore_case

Наконец, нам нужно проверить переменную окружения. Функции для работы с переменными окружения находятся в модуле env в стандартной библиотеке, поэтому мы подключаем этот модуль в область видимости в начале src/lib.rs. Затем мы будем использовать функцию var из модуля env, чтобы проверить, установлено ли какое-либо значение для переменной окружения с именем IGNORE_CASE, как показано в Листинге 12-23.

Filename: src/lib.rs
use std::env;
// --snip--

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 12-23: Проверка наличия любого значения в переменной окружения с именем IGNORE_CASE

Здесь мы создаём новую переменную ignore_case. Чтобы установить её значение, мы вызываем функцию env::var и передаём ей имя переменной окружения IGNORE_CASE. Функция env::var возвращает Result, который будет успешным вариантом Ok, содержащим значение переменной окружения, если переменная окружения установлена в любое значение. Она вернёт вариант Err, если переменная окружения не установлена.

Мы используем метод is_ok на Result, чтобы проверить, установлена ли переменная окружения, что означает, что программа должна выполнять поиск без учёта регистра. Если переменная окружения IGNORE_CASE не установлена, is_ok вернёт false, и программа выполнит поиск с учётом регистра. Нам не важно значение переменной окружения, только установлена она или нет, поэтому мы проверяем is_ok, а не используем unwrap, expect или любой другой метод, который мы видели на Result.

Мы передаём значение в переменной ignore_case в экземпляр Config, чтобы функция run могла прочитать это значение и решить, вызывать ли search_case_insensitive или search, как мы реализовали в Листинге 12-22.

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

$ cargo run -- to poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep to poem.txt`
Are you nobody, too?
How dreary to be somebody!
}}

Похоже, это всё ещё работает! Теперь давайте запустим программу с IGNORE_CASE, установленным в 1, но с тем же запросом to:

$ IGNORE_CASE=1 cargo run -- to poem.txt

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

PS> $Env:IGNORE_CASE=1; cargo run -- to poem.txt

Это сделает IGNORE_CASE постоянным на оставшуюся часть сеанса вашей оболочки. Его можно сбросить с помощью командлета Remove-Item:

PS> Remove-Item Env:IGNORE_CASE

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

Are you nobody, too?
How dreary to be somebody!
To tell your name the livelong day
To an admiring bog!

Отлично, мы также получили строки, содержащие To! Наша программа minigrep теперь может выполнять поиск без учёта регистра, управляемый переменной окружения. Теперь вы знаете, как управлять параметрами, заданными либо через аргументы командной строки, либо через переменные окружения.

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

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

Запись сообщений об ошибках в стандартный поток ошибок вместо стандартного вывода

В данный момент весь наш вывод отправляется в терминал с помощью макроса println!. В большинстве терминалов существует два вида вывода: стандартный вывод (stdout) для общей информации и стандартная ошибка (stderr) для сообщений об ошибках. Это разделение позволяет пользователям направлять успешный вывод программы в файл, но при этом видеть сообщения об ошибках на экране.

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

Проверка, куда записываются ошибки

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

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

Чтобы продемонстрировать это поведение, запустим программу с помощью > и указанного пути к файлу _output.txt_, в который мы хотим перенаправить стандартный вывод. Мы не передадим никаких аргументов, что должно вызвать ошибку:

$ cargo run > output.txt

Синтаксис > указывает оболочке записывать содержимое стандартного вывода в _output.txt_ вместо экрана. Мы не увидели ожидавшееся сообщение об ошибке на экране, значит, оно оказалось в файле. Вот что содержит _output.txt_:

Problem parsing arguments: not enough arguments

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

Вывод ошибок в стандартный поток ошибок

Мы используем код из Листинга 12-24, чтобы изменить способ вывода сообщений об ошибках. Благодаря рефакторингу, который мы выполнили ранее в этой главе, весь код, выводящий сообщения об ошибках, находится в одной функции main. Стандартная библиотека предоставляет макрос eprintln!, который выводит данные в стандартный поток ошибок, поэтому давайте изменим два места, где мы вызывали println! для вывода ошибок, на eprintln!.

Filename: src/main.rs
use std::env;
use std::process;

use minigrep::Config;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}
Listing 12-24: Запись сообщений об ошибках в стандартный поток ошибок вместо стандартного вывода с помощью eprintln!

Теперь запустим программу снова тем же способом, без аргументов и с перенаправлением стандартного вывода через >:

$ cargo run > output.txt
Problem parsing arguments: not enough arguments

Теперь мы видим ошибку на экране, а _output.txt_ остаётся пустым, что и ожидается от командной программы.

Запустим программу снова с аргументами, которые не вызывают ошибок, но всё равно перенаправим стандартный вывод в файл:

$ cargo run -- to poem.txt > output.txt

Мы не увидим никакого вывода в терминал, а _output.txt_ будет содержать наши результаты:

Имя файла: output.txt

Are you nobody, too?
How dreary to be somebody!

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

Резюме

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

Далее мы изучим некоторые возможности Rust, на которые повлияли функциональные языки: замыкания и итераторы.

Функциональные возможности языка: Итераторы и замыкания

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

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

Более конкретно мы рассмотрим:

  • Замыкания — конструкцию, похожую на функцию, которую можно сохранить в переменной
  • Итераторы — способ обработки серии элементов
  • Как использовать замыкания и итераторы для улучшения проекта ввода-вывода из главы 12
  • Производительность замыканий и итераторов (предупреждение: они быстрее, чем вы можете думать!)

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

Замыкания: анонимные функции, способные захватывать своё окружение

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

Захват окружения с помощью замыканий

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

Существует много способов реализовать это. Для этого примера мы будем использовать перечисление ShirtColor с вариантами Red и Blue (ограничиваем количество доступных цветов для простоты). Мы представляем инвентарь компании структурой Inventory с полем shirts, содержащим Vec<ShirtColor>, представляющим цвета футболок, которые сейчас есть в наличии. Метод giveaway, определённый для Inventory, получает необязательное предпочтение по цвету футболки победителя и возвращает цвет футболки, который получит человек. Эта настройка показана в Листинге 13-1:

Filename: src/main.rs
#[derive(Debug, PartialEq, Copy, Clone)]
enum ShirtColor {
    Red,
    Blue,
}

struct Inventory {
    shirts: Vec<ShirtColor>,
}

impl Inventory {
    fn giveaway(&self, user_preference: Option<ShirtColor>) -> ShirtColor {
        user_preference.unwrap_or_else(|| self.most_stocked())
    }

    fn most_stocked(&self) -> ShirtColor {
        let mut num_red = 0;
        let mut num_blue = 0;

        for color in &self.shirts {
            match color {
                ShirtColor::Red => num_red += 1,
                ShirtColor::Blue => num_blue += 1,
            }
        }
        if num_red > num_blue {
            ShirtColor::Red
        } else {
            ShirtColor::Blue
        }
    }
}

fn main() {
    let store = Inventory {
        shirts: vec![ShirtColor::Blue, ShirtColor::Red, ShirtColor::Blue],
    };

    let user_pref1 = Some(ShirtColor::Red);
    let giveaway1 = store.giveaway(user_pref1);
    println!(
        "The user with preference {:?} gets {:?}",
        user_pref1, giveaway1
    );

    let user_pref2 = None;
    let giveaway2 = store.giveaway(user_pref2);
    println!(
        "The user with preference {:?} gets {:?}",
        user_pref2, giveaway2
    );
}
Listing 13-1: Ситуация с раздачей футболок в компании

store, определённый в main, имеет две синие футболки и одну красную футболку, оставшиеся для распространения в рамках этой лимитированной серии. Мы вызываем метод giveaway для пользователя с предпочтением красной футболки и для пользователя без каких-либо предпочтений.

Опять же, этот код можно реализовать многими способами, и здесь, чтобы сосредоточиться на замыканиях, мы придерживались концепций, которые вы уже изучили, за исключением тела метода giveaway, которое использует замыкание. В методе giveaway мы получаем предпочтение пользователя как параметр типа Option<ShirtColor> и вызываем метод unwrap_or_else для user_preference. Метод unwrap_or_else для Option<T> определён в стандартной библиотеке. Он принимает один аргумент: замыкание без аргументов, которое возвращает значение T (тот же тип, что хранится в варианте Some для Option<T>, в данном случае ShirtColor). Если Option<T> — это вариант Some, unwrap_or_else возвращает значение из Some. Если Option<T> — это вариант None, unwrap_or_else вызывает замыкание и возвращает значение, возвращённое замыканием.

Мы указываем выражение замыкания || self.most_stocked() в качестве аргумента для unwrap_or_else. Это замыкание, которое само не принимает параметров (если бы замыкание имело параметры, они появились бы между двумя вертикальными чертами). Тело замыкания вызывает self.most_stocked(). Мы определяем замыкание здесь, и реализация unwrap_or_else вычислит замыкание позже, если результат понадобится.

Запуск этого кода выводит:

$ cargo run
   Compiling shirt-company v0.1.0 (file:///projects/shirt-company)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/shirt-company`
The user with preference Some(Red) gets Red
The user with preference None gets Blue

Один интересный аспект здесь в том, что мы передали замыкание, которое вызывает self.most_stocked() для текущего экземпляра Inventory. Стандартной библиотеке не нужно было знать что-либо о типах Inventory или ShirtColor, которые мы определили, или о логике, которую мы хотим использовать в этом сценарии. Замыкание захватывает неизменяемую ссылку на экземпляр Inventory self и передаёт её вместе с кодом, который мы указали, методу unwrap_or_else. Функции, с другой стороны, не могут захватывать своё окружение таким образом.

Вывод типов замыканий и аннотирование типов

Существуют и другие различия между функциями и замыканиями. Замыкания обычно не требуют от вас аннотирования типов параметров или возвращаемого значения, как это делается для функций fn. Аннотации типов требуются для функций, потому что типы являются частью явного интерфейса, предоставляемого вашим пользователям. Жёсткое определение этого интерфейса важно для обеспечения того, чтобы все соглашались с тем, какие типы значений использует функция и какие возвращает. Замыкания, с другой стороны, не используются в таком явном интерфейсе: они хранятся в переменных и используются без именования и предоставления пользователям вашей библиотеки.

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

Как и с переменными, мы можем добавить аннотации типов, если хотим повысить явность и ясность за счёт большей многословности, чем строго необходимо. Аннотирование типов для замыкания выглядело бы так, как показано в Листинге 13-2. В этом примере мы определяем замыкание и сохраняем его в переменной, а не определяем замыкание на месте, где передаём его в качестве аргумента, как мы делали в Листинге 13-1.

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn generate_workout(intensity: u32, random_number: u32) {
    let expensive_closure = |num: u32| -> u32 {
        println!("calculating slowly...");
        thread::sleep(Duration::from_secs(2));
        num
    };

    if intensity < 25 {
        println!("Today, do {} pushups!", expensive_closure(intensity));
        println!("Next, do {} situps!", expensive_closure(intensity));
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!(
                "Today, run for {} minutes!",
                expensive_closure(intensity)
            );
        }
    }
}

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(simulated_user_specified_value, simulated_random_number);
}
Listing 13-2: Добавление необязательных аннотаций типов параметров и возвращаемого значения в замыкании

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

fn  add_one_v1   (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x|             { x + 1 };
let add_one_v4 = |x|               x + 1  ;

Первая строка показывает определение функции, а вторая строка — полностью аннотированное определение замыкания. В третьей строке мы удаляем аннотации типов из определения замыкания. В четвёртой строке мы удаляем фигурные скобки, которые необязательны, потому что тело замыкания состоит только из одного выражения. Все это являются допустимыми определениями, которые будут производить одинаковое поведение при их вызове. Строки add_one_v3 и add_one_v4 требуют, чтобы замыкания были вычислены, чтобы компилятор мог вывести типы, поскольку типы будут выведены из их использования. Это аналогично let v = Vec::new();, которому либо нужны аннотации типов, либо значения некоторого типа для вставки в Vec, чтобы Rust мог вывести тип.

Для определений замыканий компилятор выведет один конкретный тип для каждого из их параметров и для их возвращаемого значения. Например, Листинг 13-3 показывает определение короткого замыкания, которое просто возвращает значение, полученное в качестве параметра. Это замыкание не очень полезно, кроме как для целей этого примера. Обратите внимание, что мы не добавили никаких аннотаций типов в определение. Поскольку нет аннотаций типов, мы можем вызвать замыкание с любым типом, что мы и сделали здесь с String в первый раз. Если затем мы попытаемся вызвать example_closure с целым числом, мы получим ошибку.

Filename: src/main.rs
fn main() {
    let example_closure = |x| x;

    let s = example_closure(String::from("hello"));
    let n = example_closure(5);
}
Listing 13-3: Попытка вызвать замыкание, типы которого выведены, с двумя разными типами

Компилятор выдаёт нам эту ошибку:

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
error[E0308]: mismatched types
 --> src/main.rs:5:29
  |
5 |     let n = example_closure(5);
  |             --------------- ^- help: try using a conversion method: `.to_string()`
  |             |               |
  |             |               expected `String`, found integer
  |             arguments to this function are incorrect
  |
note: expected because the closure was earlier called with an argument of type `String`
 --> src/main.rs:4:29
  |
4 |     let s = example_closure(String::from("hello"));
  |             --------------- ^^^^^^^^^^^^^^^^^^^^^ expected because this argument is of type `String`
  |             |
  |             in this closure call
note: closure parameter defined here
 --> src/main.rs:2:28
  |
2 |     let example_closure = |x| x;
  |                            ^

For more information about this error, try `rustc --explain E0308`.
error: could not compile `closure-example` (bin "closure-example") due to 1 previous error

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

Перемещение захваченных значений или захват ссылок

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

В Листинге 13-4 мы определяем замыкание, которое захватывает неизменяемую ссылку на вектор с именем list, потому что ему нужна только неизменяемая ссылка для печати значения:

Filename: src/main.rs
fn main() {
    let list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    let only_borrows = || println!("From closure: {list:?}");

    println!("Before calling closure: {list:?}");
    only_borrows();
    println!("After calling closure: {list:?}");
}
Listing 13-4: Определение и вызов замыкания, захватывающего неизменяемую ссылку

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

Поскольку у нас может быть несколько неизменяемых ссылок на list одновременно, list по-прежнему доступен из кода до определения замыкания, после определения замыкания, но до вызова замыкания, и после вызова замыкания. Этот код компилируется, выполняется и выводит:

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
Before calling closure: [1, 2, 3]
From closure: [1, 2, 3]
After calling closure: [1, 2, 3]

Далее, в Листинге 13-5, мы изменяем тело замыкания так, чтобы оно добавляло элемент в вектор list. Теперь замыкание захватывает изменяемую ссылку:

Filename: src/main.rs
fn main() {
    let mut list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    let mut borrows_mutably = || list.push(7);

    borrows_mutably();
    println!("After calling closure: {list:?}");
}
Listing 13-5: Определение и вызов замыкания, захватывающего изменяемую ссылку

Этот код компилируется, выполняется и выводит:

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
After calling closure: [1, 2, 3, 7]

Обратите внимание, что между определением и вызовом замыкания borrows_mutably больше нет println!: когда borrows_mutably определяется, оно захватывает изменяемую ссылку на list. Мы не используем замыкание снова после вызова замыкания, поэтому изменяемое заимствование заканчивается. Между определением замыкания и вызовом замыкания печать с неизменяемым заимствованием не разрешена, потому что никакие другие заимствования не разрешены, когда есть изменяемое заимствование. Попробуйте добавить туда println!, чтобы увидеть, какое сообщение об ошибке вы получите!

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

Эта техника в основном полезна при передаче замыкания в новый поток для перемещения данных, чтобы они принадлежали новому потоку. Мы подробно обсудим потоки и зачем их использовать в главе 16, когда будем говорить о конкурентности, но пока давайте кратко исследуем создание нового потока с использованием замыкания, которому требуется ключевое слово move. Листинг 13-6 показывает Листинг 13-4, изменённый для печати вектора в новом потоке, а не в основном потоке:

Filename: src/main.rs
use std::thread;

fn main() {
    let list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    thread::spawn(move || println!("From thread: {list:?}"))
        .join()
        .unwrap();
}
Listing 13-6: Использование move для принудительного перемещения замыкания для потока, принимающего владение list

Мы создаём новый поток, передавая потоку замыкание для выполнения в качестве аргумента. Тело замыкания печатает список. В Листинге 13-4 замыкание захватило только list с использованием неизменяемой ссылки, потому что это наименьший доступ к list, необходимый для его печати. В этом примере, даже though тело замыкания по-прежнему требует только неизменяемую ссылку, нам нужно указать, что list должен быть перемещён в замыкание, поместив ключевое слово move в начале определения замыкания. Новый поток может завершиться до того, как основной поток закончит, или основной поток может завершиться первым. Если основной поток сохранил бы владение list, но завершился бы до нового потока и удалил бы list, неизменяемая ссылка в потоке стала бы недействительной. Поэтому компилятор требует, чтобы list был перемещён в замыкание, переданное новому потоку, чтобы ссылка была действительной. Попробуйте удалить ключевое слово move или использовать list в основном потоке после определения замыкания, чтобы увидеть, какие ошибки компилятора вы получите!

Перемещение захваченных значений из замыканий и типажи Fn

После того как замыкание захватило ссылку или приняло владение значением из окружения, где замыкание определено (тем самым влияя на то, что, если что-либо, перемещается в замыкание), код в теле замыкания определяет, что происходит со ссылками или значениями, когда замыкание вычисляется позже (тем самым влияя на то, что, если что-либо, перемещается из замыкания). Тело замыкания может делать любое из следующего: переместить захваченное значение из замыкания, изменить захваченное значение, не перемещать и не изменять значение или вообще ничего не захватывать из окружения.

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

  1. FnOnce применяется к замыканиям, которые можно вызвать один раз. Все замыкания реализуют по крайней мере этот типаж, потому что все замыкания можно вызвать. Замыкание, которое перемещает захваченные значения из своего тела, будет реализовывать только FnOnce и ни один из других типажей Fn, потому что его можно вызвать только один раз.
  2. FnMut применяется к замыканиям, которые не перемещают захваченные значения из своего тела, но которые могут изменять захваченные значения. Эти замыкания можно вызывать более одного раза.
  3. Fn применяется к замыканиям, которые не перемещают захваченные значения из своего тела и которые не изменяют захваченные значения, а также к замыканиям, которые ничего не захватывают из своего окружения. Эти замыкания можно вызывать более одного раза без изменения своего окружения, что важно в таких случаях, как многократный вызов замыкания параллельно.

Давайте посмотрим на определение метода unwrap_or_else для Option<T>, который мы использовали в Листинге 13-1:

impl<T> Option<T> {
    pub fn unwrap_or_else<F>(self, f: F) -> T
    where
        F: FnOnce() -> T
    {
        match self {
            Some(x) => x,
            None => f(),
        }
    }
}

Напомним, что T — это обобщённый тип, представляющий тип значения в варианте Some для Option. Этот тип T также является возвращаемым типом функции unwrap_or_else: код, который вызывает unwrap_or_else для Option<String>, например, получит String.

Далее обратите внимание, что функция unwrap_or_else имеет дополнительный обобщённый параметр типа F. Тип F — это тип параметра с именем f, который является замыканием, которое мы предоставляем при вызове unwrap_or_else.

Ограничение типажа, указанное для обобщённого типа F, — это FnOnce() -> T, что означает, что F должен быть способен быть вызван один раз, не принимать аргументов и возвращать T. Использование FnOnce в ограничении типажа выражает требование, что unwrap_or_else вызовет f не более одного раза. В теле unwrap_or_else мы видим, что если OptionSome, f не будет вызван. Если OptionNone, f будет вызван один раз. Поскольку все замыкания реализуют FnOnce, unwrap_or_else принимает все три вида замыканий и является максимально гибким.

Примечание: Если то, что мы хотим сделать, не требует захвата значения из окружения, мы можем использовать имя функции вместо замыкания. Например, мы могли бы вызвать unwrap_or_else(Vec::new) для значения Option<Vec<T>>, чтобы получить новый пустой вектор, если значение None. Компилятор автоматически реализует whichever из типажей Fn применим для определения функции.

Теперь давайте посмотрим на метод стандартной библиотеки sort_by_key, определённый для срезов, чтобы увидеть, как он отличается от unwrap_or_else и почему sort_by_key использует FnMut вместо FnOnce для ограничения типажа. Замыкание получает один аргумент в виде ссылки на текущий элемент в рассматриваемом срезе и возвращает значение типа K, которое можно упорядочить. Эта функция полезна, когда вы хотите отсортировать срез по определённому атрибуту каждого элемента. В Листинге 13-7 у нас есть список экземпляров Rectangle, и мы используем sort_by_key, чтобы упорядочить их по атрибуту width от низкого к высокому:

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    list.sort_by_key(|r| r.width);
    println!("{list:#?}");
}
Listing 13-7: Использование sort_by_key для упорядочивания прямоугольников по ширине

Этот код выводит:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
     Running `target/debug/rectangles`
[
    Rectangle {
        width: 3,
        height: 5,
    },
    Rectangle {
        width: 7,
        height: 12,
    },
    Rectangle {
        width: 10,
        height: 1,
    },
]

Причина, по которой sort_by_key определён принимать замыкание FnMut, заключается в том, что он вызывает замыкание несколько раз: один раз для каждого элемента в срезе. Замыкание |r| r.width ничего не захватывает, не изменяет и не перемещает из своего окружения, поэтому оно соответствует требованиям ограничения типажа.

В отличие от этого, Листинг 13-8 показывает пример замыкания, которое реализует только типаж FnOnce, потому что оно перемещает значение из окружения. Компилятор не позволит нам использовать это замыкание с sort_by_key:

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    let mut sort_operations = vec![];
    let value = String::from("closure called");

    list.sort_by_key(|r| {
        sort_operations.push(value);
        r.width
    });
    println!("{list:#?}");
}
Listing 13-8: Попытка использовать замыкание FnOnce с sort_by_key

Это надуманный, запутанный способ (который не работает) попытаться подсчитать количество раз, которое sort_by_key вызывает замыкание при сортировке list. Этот код пытается сделать этот подсчёт, помещая valueString из окружения замыкания — в вектор sort_operations. Замыкание захватывает value, а затем перемещает value из замыкания, передавая владение value вектору sort_operations. Это замыкание можно вызвать один раз; попытка вызвать его второй раз не сработает, потому что value больше не будет в окружении, чтобы быть помещённым в sort_operations снова! Поэтому это замыкание реализует только FnOnce. Когда мы пытаемся скомпилировать этот код, мы получаем эту ошибку, что value нельзя переместить из замыкания, потому что замыкание должно реализовывать FnMut:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
error[E0507]: cannot move out of `value`, a captured variable in an `FnMut` closure
  --> src/main.rs:18:30
   |
15 |     let value = String::from("closure called");
   |         ----- captured outer variable
16 |
17 |     list.sort_by_key(|r| {
   |                      --- captured by this `FnMut` closure
18 |         sort_operations.push(value);
   |                              ^^^^^ move occurs because `value` has type `String`, which does not implement the `Copy` trait
   |
help: consider cloning the value if the performance cost is acceptable
   |
18 |         sort_operations.push(value.clone());
   |                                   ++++++++

For more information about this error, try `rustc --explain E0507`.
error: could not compile `rectangles` (bin "rectangles") due to 1 previous error

Ошибка указывает на строку в теле замыкания, которая перемещает value из окружения. Чтобы исправить это, нам нужно изменить тело замыкания так, чтобы оно не перемещало значения из окружения. Подсчёт в окружении и увеличение его значения в теле замыкания — это более прямой способ подсчитать количество раз, которое вызывается замыкание. Замыкание в Листинге 13-9 работает с sort_by_key, потому что оно захватывает только изменяемую ссылку на счётчик num_sort_operations и поэтому может быть вызвано более одного раза:

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    let mut num_sort_operations = 0;
    list.sort_by_key(|r| {
        num_sort_operations += 1;
        r.width
    });
    println!("{list:#?}, sorted in {num_sort_operations} operations");
}
Listing 13-9: Использование замыкания FnMut с sort_by_key разрешено

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

Обработка последовательности элементов с помощью итераторов

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

В Rust итераторы являются ленивыми, то есть они не производят эффектов, пока вы не вызовете методы, которые потребляют итератор для его использования. Например, код в Листинге 13-10 создаёт итератор по элементам вектора v1 вызовом метода iter, определённого для Vec<T>. Этот код сам по себе ничего полезного не делает.

Filename: src/main.rs
fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();
}
Listing 13-10: Создание итератора

Итератор сохраняется в переменной v1_iter. После создания итератора мы можем использовать его различными способами. В Листинге 3-5 из Главы 3 мы перебирали массив с помощью цикла for для выполнения некоторого кода над каждым его элементом. Под капотом это неявно создавало и затем потребляло итератор, но мы до сих пор упускали детали того, как именно это работает.

В примере из Листинга 13-11 мы разделяем создание итератора и его использование в цикле for. Когда цикл for вызывается с использованием итератора в v1_iter, каждый элемент итератора используется в одной итерации цикла, что выводит каждое значение.

Filename: src/main.rs
fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();

    for val in v1_iter {
        println!("Got: {val}");
    }
}
Listing 13-11: Использование итератора в цикле for

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

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

Типаж Iterator и метод next

Все итераторы реализуют типаж с именем Iterator, который определён в стандартной библиотеке. Определение типажа выглядит так:

#![allow(unused)]
fn main() {
pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;

    // методы с реализацией по умолчанию опущены
}
}

Обратите внимание, что это определение использует новый синтаксис: type Item и Self::Item, которые определяют связанный тип (associated type) для этого типажа. Мы подробно обсудим связанные типы в Главе 20. Пока вам нужно знать только, что этот код говорит: реализация типажа Iterator требует определения типа Item, и этот тип Item используется в возвращаемом типе метода next. Другими словами, тип Item будет типом, возвращаемым итератором.

Типаж Iterator требует от реализующих определить только один метод: метод next, который возвращает один элемент итератора за раз, обёрнутый в Some, а по завершении итерации возвращает None.

Мы можем вызывать метод next у итераторов напрямую; Листинг 13-12 демонстрирует, какие значения возвращаются при повторных вызовах next для итератора, созданного из вектора.

Filename: src/lib.rs
#[cfg(test)]
mod tests {
    #[test]
    fn iterator_demonstration() {
        let v1 = vec![1, 2, 3];

        let mut v1_iter = v1.iter();

        assert_eq!(v1_iter.next(), Some(&1));
        assert_eq!(v1_iter.next(), Some(&2));
        assert_eq!(v1_iter.next(), Some(&3));
        assert_eq!(v1_iter.next(), None);
    }
}
Listing 13-12: Вызов метода next у итератора

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

Также обратите внимание, что значения, которые мы получаем из вызовов next, — это неизменяемые ссылки на значения в векторе. Метод iter производит итератор по неизменяемым ссылкам. Если мы хотим создать итератор, который берёт владение v1 и возвращает владеющие значения, мы можем вызвать into_iter вместо iter. Аналогично, если мы хотим перебирать изменяемые ссылки, мы можем вызвать iter_mut вместо iter.

Методы, потребляющие итератор

Типаж Iterator имеет множество различных методов с реализациями по умолчанию, предоставляемыми стандартной библиотекой; вы можете узнать об этих методах, просмотрев документацию API стандартной библиотеки для типажа Iterator. Некоторые из этих методов вызывают метод next в своём определении, поэтому при реализации типажа Iterator от вас требуется определить метод next.

Методы, которые вызывают next, называются потребляющими адаптерами (consuming adapters), потому что их вызов использует итератор. Одним из примеров является метод sum, который берёт владение итератором и перебирает элементы, многократно вызывая next, тем самым потребляя итератор. По мере перебора он добавляет каждый элемент к текущей сумме и возвращает общую сумму по завершении итерации. Листинг 13-13 содержит тест, иллюстрирующий использование метода sum.

Filename: src/lib.rs
#[cfg(test)]
mod tests {
    #[test]
    fn iterator_sum() {
        let v1 = vec![1, 2, 3];

        let v1_iter = v1.iter();

        let total: i32 = v1_iter.sum();

        assert_eq!(total, 6);
    }
}
Listing 13-13: Вызов метода sum для получения суммы всех элементов в итераторе

Нам не разрешено использовать v1_iter после вызова sum, потому что sum берёт владение итератором, на котором мы его вызываем.

Методы, производящие другие итераторы

Адаптеры итератора (iterator adapters) — это методы, определённые в типаже Iterator, которые не потребляют итератор. Вместо этого они производят разные итераторы, изменяя какой-либо аспект исходного итератора.

Листинг 13-14 показывает пример вызова метода-адаптера итератора map, который принимает замыкание для вызова для каждого элемента по мере перебора. Метод map возвращает новый итератор, который производит изменённые элементы. Замыкание здесь создаёт новый итератор, в котором каждый элемент из вектора будет увеличен на 1:

Filename: src/main.rs
fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    v1.iter().map(|x| x + 1);
}
Listing 13-14: Вызов адаптера итератора map для создания нового итератора

Однако этот код производит предупреждение:


Код в Листинге 13-14 ничего не делает; указанное нами замыкание никогда не вызывается. Предупреждение напоминает нам почему: адаптеры итератора ленивы, и нам нужно потреблить итератор здесь.

Чтобы исправить это предупреждение и потребить итератор, мы воспользуемся методом collect, который мы использовали в Главе 12 с env::args в Листинге 12-1. Этот метод потребляет итератор и собирает результирующие значения в коллекцию.

В Листинге 13-15 мы собираем результаты перебора итератора, возвращённого вызовом map, в вектор. Этот вектор в итоге будет содержать каждый элемент из исходного вектора, увеличенный на 1.

Filename: src/main.rs
fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();

    assert_eq!(v2, vec![2, 3, 4]);
}
Listing 13-15: Вызов метода map для создания нового итератора, а затем вызов метода collect для потребления нового итератора и создания вектора

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

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

Использование замыканий, захватывающих своё окружение

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

Для этого примера мы воспользуемся методом filter, который принимает замыкание. Замыкание получает элемент из итератора и возвращает bool. Если замыкание возвращает true, значение будет включено в итерацию, производимую filter. Если замыкание возвращает false, значение не будет включено.

В Листинге 13-16 мы используем filter с замыканием, которое захватывает переменную shoe_size из своего окружения, чтобы перебрать коллекцию экземпляров структуры Shoe. Оно вернёт только обувь указанного размера.

Filename: src/lib.rs
#[derive(PartialEq, Debug)]
struct Shoe {
    size: u32,
    style: String,
}

fn shoes_in_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> {
    shoes.into_iter().filter(|s| s.size == shoe_size).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn filters_by_size() {
        let shoes = vec![
            Shoe {
                size: 10,
                style: String::from("sneaker"),
            },
            Shoe {
                size: 13,
                style: String::from("sandal"),
            },
            Shoe {
                size: 10,
                style: String::from("boot"),
            },
        ];

        let in_my_size = shoes_in_size(shoes, 10);

        assert_eq!(
            in_my_size,
            vec![
                Shoe {
                    size: 10,
                    style: String::from("sneaker")
                },
                Shoe {
                    size: 10,
                    style: String::from("boot")
                },
            ]
        );
    }
}
Listing 13-16: Использование метода filter с замыканием, захватывающим shoe_size

Функция shoes_in_size берёт владение вектором обуви и размером обуви в качестве параметров. Она возвращает вектор, содержащий только обувь указанного размера.

В теле shoes_in_size мы вызываем into_iter для создания итератора, который берёт владение вектором. Затем мы вызываем filter, чтобы адаптировать этот итератор в новый итератор, который содержит только элементы, для которых замыкание возвращает true.

Замыкание захватывает параметр shoe_size из окружения и сравнивает значение с размером каждой пары обуви, оставляя только обувь указанного размера. Наконец, вызов collect собирает значения, возвращаемые адаптированным итератором, в вектор, который возвращается функцией.

Тест показывает, что при вызове shoes_in_size мы получаем обратно только обувь, имеющую тот же размер, что и указанное значение.

Улучшение нашего I/O-проекта

Используя новые знания об итераторах, мы можем улучшить I/O-проект из Главы 12, применяя итераторы для упрощения и уточнения кода. Давайте посмотрим, как итераторы могут улучшить нашу реализацию функции Config::build и функции search.

Удаление clone с помощью итератора

В Листинге 12-6 мы добавили код, который принимал срез значений String и создавал экземпляр структуры Config путём индексации в срезе и клонирования значений, что позволяло структуре Config владеть этими значениями. В Листинге 13-17 мы воспроизвели реализацию функции Config::build такой, какой она была в Листинге 12-23.

Filename: src/lib.rs
use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 13-17: Воспроизведение функции Config::build из Листинга 12-23

В то время мы сказали, что не стоит беспокоиться о неэффективных вызовах clone, потому что мы удалим их в будущем. Что ж, это время настало!

Нам понадобился clone здесь, потому что параметр args представляет собой срез с элементами String, но функция build не владеет args. Чтобы вернуть владение экземпляром Config, нам пришлось клонировать значения полей query и file_path структуры Config, чтобы экземпляр Config мог владеть своими значениями.

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

Как только Config::build примет владение итератором и перестанет использовать операции индексации, которые заимствуют, мы сможем перемещать значения String из итератора в Config, вместо вызова clone и создания нового выделения памяти.

Прямое использование возвращаемого итератора

Откройте файл src/main.rs вашего I/O-проекта, который должен выглядеть так:

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

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    if let Err(e) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

Сначала мы изменим начало функции main, которое было в Листинге 12-24, на код из Листинга 13-18, который на этот раз использует итератор. Это не скомпилируется, пока мы не обновим Config::build.

Filename: src/main.rs
use std::env;
use std::process;

use minigrep::Config;

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    if let Err(e) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}
Listing 13-18: Передача возвращаемого значения env::args в Config::build

Функция env::args возвращает итератор! Вместо сбора значений итератора в вектор и последующей передачи среза в Config::build, теперь мы передаём владение итератором, возвращаемым из env::args, непосредственно в Config::build.

Далее нам нужно обновить определение Config::build. В файле src/lib.rs вашего I/O-проекта изменим сигнатуру Config::build так, как показано в Листинге 13-19. Это всё ещё не скомпилируется, потому что нам нужно обновить тело функции.

Filename: src/lib.rs
use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        // --snip--
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 13-19: Обновление сигнатуры Config::build для ожидания итератора

Документация стандартной библиотеки для функции env::args показывает, что тип возвращаемого итератора — std::env::Args, и этот тип реализует типаж Iterator и возвращает значения String.

Мы обновили сигнатуру функции Config::build так, чтобы параметр args имел обобщённый тип с ограничениями типажа impl Iterator<Item = String> вместо &[String]. Это использование синтаксиса impl Trait, о котором мы говорили в разделе “Типажи как параметры” Главы 10, означает, что args может быть любым типом, который реализует типаж Iterator и возвращает элементы String.

Поскольку мы принимаем владение args и будем изменять args путём итерации по нему, мы можем добавить ключевое слово mut в спецификацию параметра args, сделав его изменяемым.

Использование методов типажа Iterator вместо индексации

Далее исправим тело Config::build. Поскольку args реализует типаж Iterator, мы знаем, что можем вызвать метод next на нём! Листинг 13-20 обновляет код из Листинга 12-23 для использования метода next.

Filename: src/lib.rs
use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a query string"),
        };

        let file_path = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a file path"),
        };

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 13-20: Изменение тела Config::build для использования методов итератора

Помните, что первое значение в возвращаемом значении env::args — это имя программы. Мы хотим проигнорировать его и перейти к следующему значению, поэтому сначала вызываем next и ничего не делаем с возвращаемым значением. Затем мы вызываем next, чтобы получить значение, которое хотим поместить в поле query структуры Config. Если next возвращает Some, мы используем match для извлечения значения. Если он возвращает None, это означает, что не было передано достаточно аргументов, и мы досрочно возвращаем значение Err. Мы делаем то же самое для значения file_path.

Упрощение кода с помощью адаптеров итератора

Мы также можем воспользоваться итераторами в функции search нашего I/O-проекта, которая воспроизведена здесь в Листинге 13-21 такой, какой она была в Листинге 12-19:

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 13-21: Реализация функции search из Листинга 12-19

Мы можем записать этот код более кратко, используя методы-адаптеры итератора. Это также позволяет нам избежать создания изменяемого промежуточного вектора results. Функциональный стиль программирования предпочитает минимизировать количество изменяемого состояния, чтобы сделать код более понятным. Удаление изменяемого состояния может позволить в будущем улучшить поиск, сделав его параллельным, поскольку нам не пришлось бы управлять одновременным доступом к вектору results. Листинг 13-22 показывает это изменение:

Filename: src/lib.rs
use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a query string"),
        };

        let file_path = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a file path"),
        };

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    contents
        .lines()
        .filter(|line| line.contains(query))
        .collect()
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 13-22: Использование методов-адаптеров итератора в реализации функции search

Напомним, что цель функции search — вернуть все строки в contents, которые содержат query. Подобно примеру с filter в Листинге 13-16, этот код использует адаптер filter для сохранения только тех строк, для которых line.contains(query) возвращает true. Затем мы собираем совпадающие строки в другой вектор с помощью collect. Намного проще! Не стесняйтесь сделать то же изменение, используя методы итератора, в функции search_case_insensitive.

Выбор между циклами или итераторами

Следующий логический вопрос — какой стиль вы должны выбрать в своём собственном коде и почему: оригинальную реализацию из Листинга 13-21 или версию с итераторами из Листинга 13-22. Большинство программистов Rust предпочитают использовать стиль с итераторами. Сначала с ним сложнее освоиться, но как только вы почувствуете различные адаптеры итераторов и поймёте, что они делают, итераторы могут стать легче для понимания. Вместо возни с различными частями цикла и построением новых векторов код фокусируется на высокой цели цикла. Это абстрагирует некоторый обычный код, чтобы было легче увидеть концепции, уникальные для этого кода, такие как условие фильтрации, которое должен пройти каждый элемент итератора.

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

Сравнение производительности: циклы и итераторы

Чтобы определить, что использовать — циклы или итераторы, — нужно знать, какая реализация быстрее: версия функции search с явным циклом for или версия с итераторами.

Мы провели бенчмарк, загрузив полное содержимое книги «Приключения Шерлока Холмса» сэра Артура Конана Дойля в String и выполнив поиск слова «the. Вот результаты для версии searchс цикломfor` и версии с итераторами:

test bench_search_for  ... bench:  19,620,300 ns/iter (+/- 915,700)
test bench_search_iter ... bench:  19,234,900 ns/iter (+/- 657,200)

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

Для более комплексного бенчмарка стоит использовать тексты разного размера в качестве contents, разные слова и слова разной длины в качестве query, а также другие варианты. Суть в следующем: итераторы, несмотря на высокоуровневую абстракцию, компилируются в код, примерно равный тому, который вы написали бы на низком уровне самостоятельно. Итераторы — это одна из абстракций нулевой стоимости Rust, под которой мы подразумеваем, что использование абстракции не несёт дополнительных накладных расходов во время выполнения. Это аналогично определению нулевых накладных расходов (zero-overhead) Бьёрном Страуструпом, оригинальным разработчиком и реализатором C++, в работе «Основы C++» (2012):

В целом, реализации C++ соблюдают принцип нулевых накладных расходов: то, что вы не используете, вы не платите за это. И далее: то, что вы используете, вы не можете написать вручную лучше.

В качестве другого примера приведён код из аудио-декодера. Алгоритм декодирования использует математическую операцию линейного предсказания для оценки будущих значений на основе линейной функции предыдущих сэмплов. Этот код использует цепочку итераторов для выполнения вычислений с тремя переменными в области видимости: срезом данных buffer, массивом из 12 coefficients и величиной сдвига qlp_shift. Мы объявили переменные в этом примере, но не задали им значения; хотя этот код имеет мало смысла вне контекста, он остаётся кратким реальным примером того, как Rust преобразует высокоуровневые идеи в низкоуровневый код.

let buffer: &mut [i32];
let coefficients: [i64; 12];
let qlp_shift: i16;

for i in 12..buffer.len() {
    let prediction = coefficients.iter()
                                 .zip(&buffer[i - 12..i])
                                 .map(|(&c, &s)| c * s as i64)
                                 .sum::<i64>() >> qlp_shift;
    let delta = buffer[i];
    buffer[i] = prediction as i32 + delta;
}

Чтобы вычислить prediction, этот код проходит по каждому из 12 значений в coefficients и использует метод zip для сопоставления значений коэффициентов с предыдущими 12 значениями в buffer. Затем для каждой пары мы перемножаем значения, суммируем результаты и сдвигаем биты в сумме на qlp_shift бит вправо.

Вычисления в таких приложениях, как аудио-декодеры, часто ставят производительность на первое место. Здесь мы создаём итератор, используем два адаптера, а затем потребляем значение. В какой ассемблерный код скомпилируется этот Rust-код? На момент написания он компилируется в тот же ассемблерный код, который вы написали бы вручную. Здесь вообще нет цикла, соответствующего итерации по значениям в coefficients: Rust знает, что итераций 12, поэтому «разворачивает» цикл. Разворачивание — это оптимизация, которая удаляет накладные расходы на управление циклом и вместо этого генерирует повторяющийся код для каждой итерации цикла.

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

Краткий итог

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

Теперь, когда мы улучшили выразительность нашего проекта ввода-вывода, рассмотрим некоторые дополнительные возможности cargo, которые помогут нам поделиться проектом с миром.

Подробнее о Cargo и crates.io

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

  • Настраивать сборку с помощью профилей сборки
  • Публиковать библиотеки на crates.io
  • Организовывать крупные проекты с помощью рабочих пространств
  • Устанавливать бинарные файлы с crates.io
  • Расширять Cargo с помощью пользовательских команд

Cargo может делать даже больше, чем описано в этой главе. Полное описание всех его возможностей см. в его документации.

Настройка сборки с помощью профилей выпуска

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

У Cargo есть два основных профиля: профиль dev, который Cargo использует при выполнении cargo build, и профиль release, который Cargo использует при выполнении cargo build --release. Профиль dev задан с хорошими значениями по умолчанию для разработки, а профиль release — с хорошими значениями по умолчанию для сборок выпуска.

Эти имена профилей могут быть знакомы вам из вывода сборки:

$ cargo build
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
$ cargo build --release
    Finished `release` profile [optimized] target(s) in 0.32s

dev и release — это разные профили, используемые компилятором.

У Cargo есть настройки по умолчанию для каждого профиля, которые применяются, когда вы явно не добавили разделы [profile.*] в файл Cargo.toml проекта. Добавляя разделы [profile.*] для любого профиля, который вы хотите настроить, вы переопределяете любую часть настроек по умолчанию. Например, вот значения по умолчанию для настройки opt-level для профилей dev и release:

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

[profile.dev]
opt-level = 0

[profile.release]
opt-level = 3

Настройка opt-level контролирует количество оптимизаций, которые Rust применит к вашему коду, в диапазоне от 0 до 3. Применение большего количества оптимизаций увеличивает время компиляции. Поэтому, если вы находитесь в процессе разработки и часто компилируете код, вы захотите меньше оптимизаций, чтобы компиляция была быстрее, даже если итоговый код будет работать медленнее. Поэтому значение opt-level по умолчанию для dev равно 0. Когда вы готовы выпустить свой код, лучше потратить больше времени на компиляцию. Вы будете компилировать в режиме выпуска только один раз, но будете запускать скомпилированную программу много раз. Поэтому режим выпуска жертвует более долгим временем компиляции ради кода, который работает быстрее. Вот почему значение opt-level по умолчанию для профиля release равно 3.

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

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

[profile.dev]
opt-level = 1

Этот код переопределяет настройку по умолчанию 0. Теперь, когда мы запускаем cargo build, Cargo будет использовать настройки по умолчанию для профиля dev плюс нашу настройку для opt-level. Поскольку мы установили opt-level в 1, Cargo применит больше оптимизаций, чем по умолчанию, но не так много, как в сборке выпуска.

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

Публикация крейта на Crates.io

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

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

Создание полезных документационных комментариев

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

Документационные комментарии используют три косые черты, ///, вместо двух и поддерживают разметку Markdown для форматирования текста. Размещайте документационные комментарии непосредственно перед элементом, который они документируют. Листинг 14-1 показывает документационные комментарии для функции add_one в крейте с именем my_crate.

Filename: src/lib.rs
/// Adds one to the number given.
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
    x + 1
}
Listing 14-1: Документационный комментарий для функции

Здесь мы даем описание того, что делает функция add_one, начинаем раздел с заголовком Examples (Примеры), а затем предоставляем код, демонстрирующий, как использовать функцию add_one. Мы можем сгенерировать HTML-документацию из этого документационного комментария, выполнив cargo doc. Эта команда запускает инструмент rustdoc, поставляемый с Rust, и помещает сгенерированную HTML- документацию в каталог target/doc.

Для удобства выполнение cargo doc --open соберет HTML для документации вашего текущего крейта (а также документации для всех зависимостей вашего крейта) и откроет результат в веб-браузере. Перейдите к функции add_one, и вы увидите, как отображается текст в документационных комментариях, как показано на Рисунке 14-1:

Отрендеренная HTML-документация для функции `add_one` крейта `my_crate`

Рисунок 14-1: HTML-документация для функции add_one

Часто используемые разделы

Мы использовали заголовок Markdown # Examples в Листинге 14-1 для создания раздела в HTML с заголовком «Examples» (Примеры). Вот некоторые другие разделы, которые авторы крейтов часто используют в своей документации:

  • Panics (Паника): Сценарии, в которых документируемая функция может вызвать панику. Вызывающие функцию, которые не хотят, чтобы их программы паниковали, должны убедиться, что они не вызывают функцию в этих ситуациях.
  • Errors (Ошибки): Если функция возвращает Result, описание видов ошибок, которые могут возникнуть, и условий, которые могут вызвать эти ошибки, может быть полезно для вызывающих, чтобы они могли писать код для обработки разных видов ошибок по-разному.
  • Safety (Безопасность): Если вызов функции unsafe (небезопасен) (мы обсуждаем небезопасность в Главе 20), должен быть раздел, объясняющий, почему функция небезопасна и охватывающий инварианты, которые функция ожидает от вызывающих.

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

Документационные комментарии как тесты

Добавление примеров кода в ваши документационные комментарии может помочь продемонстрировать, как использовать вашу библиотеку, и это имеет дополнительное преимущество: выполнение cargo test будет запускать примеры кода в вашей документации как тесты! Нет ничего лучше, чем документация с примерами. Но нет ничего хуже, чем примеры, которые не работают, потому что код изменился с тех пор, как документация была написана. Если мы запустим cargo test с документацией для функции add_one из Листинга 14-1, мы увидим раздел в результатах теста, который выглядит так:

   Doc-tests my_crate

running 1 test
test src/lib.rs - add_one (line 5) ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.27s

Теперь, если мы изменим либо функцию, либо пример так, чтобы assert_eq! в примере вызвал панику, и снова запустим cargo test, мы увидим, что документационные тесты обнаруживают, что пример и код не синхронизированы друг с другом!

Комментирование содержащихся элементов

Стиль документационного комментария //! добавляет документацию к элементу, который содержит комментарии, а не к элементам, следующим за комментариями. Мы обычно используем эти документационные комментарии внутри файла корня крейта (src/lib.rs по соглашению) или внутри модуля для документирования крейта или модуля в целом.

Например, чтобы добавить документацию, описывающую назначение крейта my_crate, содержащего функцию add_one, мы добавляем документационные комментарии, начинающиеся с //!, в начало файла src/lib.rs, как показано в Листинге 14-2:

Filename: src/lib.rs
//! # My Crate
//!
//! `my_crate` is a collection of utilities to make performing certain
//! calculations more convenient.

/// Adds one to the number given.
// --snip--
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
    x + 1
}
Listing 14-2: Документация для крейта my_crate в целом

Обратите внимание, что после последней строки, начинающейся с //!, нет кода. Поскольку мы начали комментарии с //! вместо ///, мы документируем элемент, который содержит этот комментарий, а не элемент, который следует за этим комментарием. В этом случае этим элементом является файл src/lib.rs, который является корнем крейта. Эти комментарии описывают весь крейт.

Когда мы запускаем cargo doc --open, эти комментарии будут отображаться на первой странице документации для my_crate выше списка публичных элементов в крейте, как показано на Рисунке 14-2.

Отрендеренная документация с комментарием для крейта в целом

Рисунок 14-2: Отрендеренная документация для my_crate, включающая комментарий, описывающий крейт в целом

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

Экспорт удобного публичного API с помощью pub use

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

В Главе 7 мы рассмотрели, как делать элементы публичными с помощью ключевого слова pub и как подключать элементы в область видимости с помощью ключевого слова use. Однако структура, которая имеет смысл для вас во время разработки крейта, может быть не очень удобной для ваших пользователей. Вы можете захотеть организовать свои структуры в иерархию, содержащую несколько уровней, но тогда люди, которые хотят использовать тип, определенный глубоко в иерархии, могут испытывать трудности с обнаружением существования этого типа. Они также могут раздражаться, вводя use my_crate::some_module::another_module::UsefulType; вместо use my_crate::UsefulType;.

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

Например, предположим, мы создали библиотеку под названием art для моделирования художественных концепций. Внутри этой библиотеки есть два модуля: модуль kinds, содержащий два перечисления PrimaryColor и SecondaryColor, и модуль utils, содержащий функцию mix, как показано в Листинге 14-3:

Filename: src/lib.rs
//! # Art
//!
//! A library for modeling artistic concepts.

pub mod kinds {
    /// The primary colors according to the RYB color model.
    pub enum PrimaryColor {
        Red,
        Yellow,
        Blue,
    }

    /// The secondary colors according to the RYB color model.
    pub enum SecondaryColor {
        Orange,
        Green,
        Purple,
    }
}

pub mod utils {
    use crate::kinds::*;

    /// Combines two primary colors in equal amounts to create
    /// a secondary color.
    pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
        // --snip--
        unimplemented!();
    }
}
Listing 14-3: Библиотека art с элементами, организованными в модули kinds и utils

Рисунок 14-3 показывает, как будет выглядеть первая страница документации для этого крейта, сгенерированной cargo doc:

Отрендеренная документация для крейта `art`, в которой перечислены модули `kinds` и `utils`

Рисунок 14-3: Первая страница документации для art, в которой перечислены модули kinds и utils

Обратите внимание, что типы PrimaryColor и SecondaryColor не перечислены на первой странице, как и функция mix. Нам нужно нажать kinds и utils, чтобы увидеть их.

Другой крейт, зависящий от этой библиотеки, потребует операторов use, подключающих элементы из art в область видимости, с указанием текущей определенной модульной структуры. Листинг 14-4 показывает пример крейта, использующего элементы PrimaryColor и mix из крейта art:

Filename: src/main.rs
use art::kinds::PrimaryColor;
use art::utils::mix;

fn main() {
    let red = PrimaryColor::Red;
    let yellow = PrimaryColor::Yellow;
    mix(red, yellow);
}
Listing 14-4: Крейт, использующий элементы крейта art с его внутренней структурой, экспортированной

Автору кода в Листинге 14-4, который использует крейт art, пришлось выяснить, что PrimaryColor находится в модуле kinds, а mix — в модуле utils. Модульная структура крейта art более актуальна для разработчиков, работающих над крейтом art, чем для тех, кто его использует. Внутренняя структура не содержит полезной информации для того, кто пытается понять, как использовать крейт art, а скорее вызывает путаницу, потому что разработчики, которые его используют, должны выяснять, где искать, и должны указывать имена модулей в операторах use.

Чтобы удалить внутреннюю организацию из публичного API, мы можем изменить код крейта art в Листинге 14-3, добавив операторы pub use для повторного экспорта элементов на верхнем уровне, как показано в Листинге 14-5:

Filename: src/lib.rs
//! # Art
//!
//! A library for modeling artistic concepts.

pub use self::kinds::PrimaryColor;
pub use self::kinds::SecondaryColor;
pub use self::utils::mix;

pub mod kinds {
    // --snip--
    /// The primary colors according to the RYB color model.
    pub enum PrimaryColor {
        Red,
        Yellow,
        Blue,
    }

    /// The secondary colors according to the RYB color model.
    pub enum SecondaryColor {
        Orange,
        Green,
        Purple,
    }
}

pub mod utils {
    // --snip--
    use crate::kinds::*;

    /// Combines two primary colors in equal amounts to create
    /// a secondary color.
    pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
        SecondaryColor::Orange
    }
}
Listing 14-5: Добавление операторов pub use для повторного экспорта элементов

Документация API, которую cargo doc генерирует для этого крейта, теперь будет перечислять и ссылаться на повторные экспорты на первой странице, как показано на Рисунке 14-4, что упрощает поиск типов PrimaryColor и SecondaryColor и функции mix.

Отрендеренная документация для крейта `art` с повторными экспортами на первой странице

Рисунок 14-4: Первая страница документации для art, в которой перечислены повторные экспорты

Пользователи крейта art по-прежнему могут видеть и использовать внутреннюю структуру из Листинга 14-3, как показано в Листинге 14-4, или они могут использовать более удобную структуру из Листинга 14-5, как показано в Листинге 14-6:

Filename: src/main.rs
use art::PrimaryColor;
use art::mix;

fn main() {
    // --snip--
    let red = PrimaryColor::Red;
    let yellow = PrimaryColor::Yellow;
    mix(red, yellow);
}
Listing 14-6: Программа, использующая повторно экспортированные элементы из крейта art

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

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

Настройка учетной записи на Crates.io

Прежде чем вы сможете публиковать какие-либо крейты, вам нужно создать учетную запись на crates.io и получить API-токен. Для этого посетите домашнюю страницу на crates.io и войдите через учетную запись GitHub. (Учетная запись GitHub в настоящее время является обязательным требованием, но в будущем сайт может поддерживать другие способы создания учетной записи.) Как только вы войдете в систему, посетите настройки своей учетной записи по адресу https://crates.io/me/ и получите свой API-ключ. Затем выполните команду cargo login и вставьте ваш API-ключ, когда он запросится, вот так:

$ cargo login
abcdefghijklmnopqrstuvwxyz012345

Эта команда сообщит Cargo ваш API-токен и сохранит его локально в ~/.cargo/credentials. Обратите внимание, что этот токен является секретом: не делитесь им с кем-либо еще. Если вы все же поделитесь им по какой-либо причине, вы должны отозвать его и сгенерировать новый токен на crates.io.

Добавление метаданных в новый крейт

Предположим, у вас есть крейт, который вы хотите опубликовать. Перед публикацией вам нужно добавить некоторые метаданные в раздел [package] файла Cargo.toml вашего крейта.

Вашему крейту понадобится уникальное имя. Пока вы работаете над крейтом локально, вы можете назвать крейт как хотите. Однако имена крейтов на crates.io выделяются по принципу «первый пришел — первый получил». Как только имя крейта занято, никто другой не может опубликовать крейт с этим именем. Перед попыткой опубликовать крейт найдите имя, которое вы хотите использовать. Если имя уже использовалось, вам нужно будет найти другое имя и отредактировать поле name в файле Cargo.toml в разделе [package], чтобы использовать новое имя для публикации, вот так:

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

[package]
name = "guessing_game"

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

$ cargo publish
    Updating crates.io index
warning: manifest has no description, license, license-file, documentation, homepage or repository.
See https://doc.rust-lang.org/cargo/reference/manifest.html#package-metadata for more info.
--snip--
error: failed to publish to registry at https://crates.io

Caused by:
  the remote server responded with an error (status 400 Bad Request): missing or empty metadata fields: description, license. Please see https://doc.rust-lang.org/cargo/reference/manifest.html for more information on configuring these fields

Это приводит к ошибке, потому что вам не хватает некоторой важной информации: описание и лицензия требуются, чтобы люди знали, что делает ваш крейт и на каких условиях они могут его использовать. В Cargo.toml добавьте описание, которое состоит всего из одного-двух предложений, поскольку оно будет отображаться с вашим крейтом в результатах поиска. Для поля license вам нужно указать идентификатор лицензии. В списке лицензий Software Package Data Exchange (SPDX) Фонда Linux перечислены идентификаторы, которые вы можете использовать для этого значения. Например, чтобы указать, что вы лицензировали свой крейт, используя лицензию MIT, добавьте идентификатор MIT:

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

[package]
name = "guessing_game"
license = "MIT"

Если вы хотите использовать лицензию, которая не указана в SPDX, вам нужно поместить текст этой лицензии в файл, включить файл в ваш проект, а затем использовать license-file, чтобы указать имя этого файла вместо использования ключа license.

Руководство по выбору подходящей лицензии для вашего проекта выходит за рамки этой книги. Многие люди в сообществе Rust лицензируют свои проекты так же, как Rust, используя двойную лицензию MIT OR Apache-2.0. Эта практика демонстрирует, что вы также можете указать несколько идентификаторов лицензий, разделенных OR, чтобы иметь несколько лицензий для своего проекта.

С уникальным именем, версией, вашим описанием и лицензией файл Cargo.toml для проекта, готового к публикации, может выглядеть так:

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

[package]
name = "guessing_game"
version = "0.1.0"
edition = "2024"
description = "A fun game where you guess what number the computer has chosen."
license = "MIT OR Apache-2.0"

[dependencies]

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

Публикация на Crates.io

Теперь, когда вы создали учетную запись, сохранили свой API-токен, выбрали имя для своего крейта и указали требуемые метаданные, вы готовы к публикации! Публикация крейта загружает конкретную версию на crates.io для использования другими.

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

Запустите команду cargo publish еще раз. Теперь она должна завершиться успешно:

$ cargo publish
    Updating crates.io index
   Packaging guessing_game v0.1.0 (file:///projects/guessing_game)
   Verifying guessing_game v0.1.0 (file:///projects/guessing_game)
   Compiling guessing_game v0.1.0
(file:///projects/guessing_game/target/package/guessing_game-0.1.0)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.19s
   Uploading guessing_game v0.1.0 (file:///projects/guessing_game)

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

Публикация новой версии существующего крейта

Когда вы внесли изменения в свой крейт и готовы выпустить новую версию, вы изменяете значение version, указанное в вашем файле Cargo.toml, и снова публикуете. Используйте правила семантического версионирования, чтобы решить, какое подходящее следующее номер версии на основе внесенных изменений. Затем запустите cargo publish, чтобы загрузить новую версию.

Устаревание версий на Crates.io с помощью cargo yank

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

Отзыв (Yanking) версии предотвращает зависимость новых проектов от этой версии, позволяя при этом всем существующим проектам, зависящим от нее, продолжать работу. По сути, отзыв означает, что все проекты с файлом Cargo.lock не сломаются, и любые будущие файлы Cargo.lock, сгенерированные, не будут использовать отозванную версию.

Чтобы отозвать версию крейта, в каталоге крейта, который вы ранее опубликовали, запустите cargo yank и укажите, какую версию вы хотите отозвать. Например, если мы опубликовали крейт с именем guessing_game версии 1.0.1 и хотим его отозвать, в каталоге проекта для guessing_game мы запустим:

$ cargo yank --vers 1.0.1
    Updating crates.io index
        Yank guessing_game@1.0.1

Добавив --undo к команде, вы также можете отменить отзыв и снова разрешить проектам зависеть от версии:

$ cargo yank --vers 1.0.1 --undo
    Updating crates.io index
      Unyank guessing_game@1.0.1

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

Рабочие пространства Cargo

В Главе 12 мы создали пакет, включающий бинарный крейт и библиотечный крейт. По мере развития проекта вы можете обнаружить, что библиотечный крейт продолжает расти, и вы хотите разделить пакет на несколько библиотечных крейтов. Cargo предлагает возможность под названием рабочие пространства (workspaces), которая помогает управлять несколькими связанными пакетами, разрабатываемыми одновременно.

Создание рабочего пространства

Рабочее пространство — это набор пакетов, которые используют один и тот же файл Cargo.lock и одну выходную директорию. Давайте создадим проект с использованием рабочего пространства — мы будем использовать тривиальный код, чтобы сосредоточиться на структуре рабочего пространства. Существует несколько способов структурировать рабочее пространство, поэтому мы покажем только один распространённый вариант. У нас будет рабочее пространство, содержащее один бинарный крейт и две библиотеки. Бинарный крейт, который будет предоставлять основную функциональность, будет зависеть от двух библиотек. Одна библиотека будет предоставлять функцию add_one, а другая — функцию add_two. Эти три крейта будут частью одного рабочего пространства. Начнём с создания новой директории для рабочего пространства:

$ mkdir add
$ cd add

Далее, в директории add, мы создадим файл Cargo.toml, который будет конфигурировать всё рабочее пространство. В этом файле не будет секции [package]. Вместо этого он начнётся с секции [workspace], которая позволит нам добавлять участников (members) в рабочее пространство. Мы также специально укажем использовать последнюю и лучшую версию алгоритма резолвера Cargo в нашем рабочем пространстве, установив resolver в "3".

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

[workspace]
resolver = "3"

Далее мы создадим бинарный крейт adder, выполнив cargo new внутри директории add:

$ cargo new adder
    Creating binary (application) `adder` package
      Adding `adder` as member of workspace at `file:///projects/add`

Запуск cargo new внутри рабочего пространства также автоматически добавляет вновь созданный пакет в ключ members в определении [workspace] в файле Cargo.toml рабочего пространства, вот так:

[workspace]
resolver = "3"
members = ["adder"]

На этом этапе мы можем собрать рабочее пространство, выполнив cargo build в корневой директории add. Файлы в вашей директории add должны выглядеть так:

├── Cargo.lock
├── Cargo.toml
├── adder
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── target

Рабочее пространство имеет одну директорию target на верхнем уровне, в которую будут помещаться скомпилированные артефакты; у пакета adder нет своей собственной директории target. Даже если мы запустим cargo build изнутри директории adder, скомпилированные артефакты всё равно окажутся в add/target, а не в add/adder/target. Cargo структурирует директорию target в рабочем пространстве именно так, потому что крейты в рабочем пространстве должны зависеть друг от друга. Если бы у каждого крейта была своя директория target, каждому крейту пришлось бы перекомпилировать все остальные крейты в рабочем пространстве, чтобы разместить артефакты в своей собственной директории target. Используя одну общую директорию target, крейты могут избежать ненужных пересборок.

Создание второго пакета в рабочем пространстве

Далее создадим ещё один пакет-участник в рабочем пространстве и назовём его add_one. Сгенерируем новый библиотечный крейт с именем add_one:

$ cargo new add_one --lib
    Creating library `add_one` package
      Adding `add_one` as member of workspace at `file:///projects/add`

Теперь верхний Cargo.toml будет включать путь add_one в список members:

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

[workspace]
resolver = "3"
members = ["adder", "add_one"]

Ваша директория add теперь должна содержать эти директории и файлы:

├── Cargo.lock
├── Cargo.toml
├── add_one
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
├── adder
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── target

В файле add_one/src/lib.rs добавим функцию add_one:

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

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

Теперь мы можем сделать так, чтобы пакет adder с нашим бинарным крейтом зависел от пакета add_one с нашей библиотекой. Сначала нам нужно добавить зависимость по пути (path dependency) на add_one в файл adder/Cargo.toml.

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

[dependencies]
add_one = { path = "../add_one" }

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

Далее давайте используем функцию add_one (из крейта add_one) в крейте adder. Откройте файл adder/src/main.rs и измените функцию main так, чтобы она вызывала функцию add_one, как в Листинге 14-7.

Filename: adder/src/main.rs
fn main() {
    let num = 10;
    println!("Hello, world! {num} plus one is {}!", add_one::add_one(num));
}
Listing 14-7: Использование библиотечного крейта add_one в крейте adder

Давайте соберём рабочее пространство, выполнив cargo build в верхней директории add!

$ cargo build
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.22s

Чтобы запустить бинарный крейт из директории add, мы можем указать, какой пакет в рабочем пространстве мы хотим запустить, используя аргумент -p и имя пакета с cargo run:

$ cargo run -p adder
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/adder`
Hello, world! 10 plus one is 11!

Это запускает код в adder/src/main.rs, который зависит от крейта add_one.

Зависимость от внешнего пакета в рабочем пространстве

Обратите внимание, что рабочее пространство имеет только один файл Cargo.lock на верхнем уровне, а не по Cargo.lock в каждой директории крейта. Это гарантирует, что все крейты используют одну и ту же версию всех зависимостей. Если мы добавим пакет rand в файлы adder/Cargo.toml и add_one/Cargo.toml, Cargo разрешит обе зависимости к одной версии rand и запишет это в один Cargo.lock. Обеспечение использования всеми крейтами в рабочем пространстве одних и тех же зависимостей означает, что крейты всегда будут совместимы друг с другом. Давайте добавим крейт rand в секцию [dependencies] в файле add_one/Cargo.toml, чтобы мы могли использовать крейт rand в крейте add_one:

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

[dependencies]
rand = "0.8.5"

Теперь мы можем добавить use rand; в файл add_one/src/lib.rs, и сборка всего рабочего пространства выполненная cargo build в директории add подтянет и скомпилирует крейт rand. Мы получим одно предупреждение, потому что не используем импортированный rand:

$ cargo build
    Updating crates.io index
  Downloaded rand v0.8.5
   --snip--
   Compiling rand v0.8.5
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
warning: unused import: `rand`
 --> add_one/src/lib.rs:1:5
  |
1 | use rand;
  |     ^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

warning: `add_one` (lib) generated 1 warning (run `cargo fix --lib -p add_one` to apply 1 suggestion)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.95s

Верхний Cargo.lock теперь содержит информацию о зависимости add_one от rand. Однако, хотя rand используется где-то в рабочем пространстве, мы не можем использовать его в других крейтах в рабочем пространстве, если не добавим rand и в их файлы Cargo.toml. Например, если мы добавим use rand; в файл adder/src/main.rs для пакета adder, мы получим ошибку:

$ cargo build
  --snip--
   Compiling adder v0.1.0 (file:///projects/add/adder)
error[E0432]: unresolved import `rand`
 --> adder/src/main.rs:2:5
  |
2 | use rand;
  |     ^^^^ no external crate `rand`

Чтобы исправить это, отредактируйте файл Cargo.toml для пакета adder и укажите, что rand также является для него зависимостью. Сборка пакета adder добавит rand в список зависимостей для adder в Cargo.lock, но дополнительные копии rand загружаться не будут. Cargo гарантирует, что каждый крейт в каждом пакете рабочего пространства, использующий пакет rand, будет использовать одну и ту же версию, при условии что они указывают совместимые версии rand, экономя место и обеспечивая совместимость крейтов в рабочем пространстве.

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

Обратите внимание, что Cargo обеспечивает совместимость только в рамках правил [Семантического версионирования]. Например, предположим, что в рабочем пространстве один крейт зависит от rand 0.8.0, а другой — от rand 0.8.1. Правила semver говорят, что 0.8.1 совместим с 0.8.0, поэтому оба крейта будут зависеть от 0.8.1 (или потенциально более позднего патча, например, 0.8.2). Но если один крейт зависит от rand 0.7.0, а другой — от rand 0.8.0, эти версии семантически несовместимы. Следовательно, Cargo будет использовать разные версии rand для каждого крейта.

Добавление теста в рабочее пространство

В качестве ещё одного улучшения давайте добавим тест функции add_one::add_one внутри крейта add_one:

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

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

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        assert_eq!(3, add_one(2));
    }
}

Теперь запустите cargo test в верхней директории add. Запуск cargo test в рабочем пространстве, структурированном подобно этому, выполнит тесты для всех крейтов в рабочем пространстве:

$ cargo test
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.20s
     Running unittests src/lib.rs (target/debug/deps/add_one-93c49ee75dc46543)

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

     Running unittests src/main.rs (target/debug/deps/adder-3a47283c568d2b6a)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests add_one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Первая часть вывода показывает, что тест it_works в крейте add_one прошёл. Следующая часть показывает, что в крейте adder не было найдено тестов, а последняя часть показывает, что не было найдено документационных тестов в крейте add_one.

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

$ cargo test -p add_one
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running unittests src/lib.rs (target/debug/deps/add_one-93c49ee75dc46543)

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 add_one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Этот вывод показывает, что cargo test запустил тесты только для крейта add_one и не запускал тесты для крейта adder.

Если вы публикуете крейты в рабочем пространстве на crates.io, каждый крейт в рабочем пространстве нужно будет публиковать отдельно. Как и cargo test, мы можем опубликовать конкретный крейт в нашем рабочем пространстве, используя флаг -p и указав имя крейта, который мы хотим опубликовать.

Для дополнительной практики добавьте крейт add_two в это рабочее пространство подобно крейту add_one!

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

Установка бинарных файлов с помощью cargo install

Команда cargo install позволяет устанавливать и использовать бинарные крейты локально. Это не предназначено для замены системных пакетов; её цель — предоставить удобный способ разработчикам Rust устанавливать инструменты, которыми другие поделились на crates.io. Обратите внимание, что вы можете устанавливать только пакеты, имеющие бинарные цели. Бинарная цель — это запускаемая программа, которая создаётся, если в крейте есть файл src/main.rs или другой файл, указанный как бинарный, в отличие от библиотечной цели, которая сама по себе не запускаема, но пригодна для включения в другие программы. Обычно в файле README крейта есть информация о том, является ли он библиотекой, имеет ли бинарную цель или и то, и другое.

Все бинарные файлы, установленные через cargo install, сохраняются в папке bin корня установки. Если вы установили Rust с помощью rustup.rs и не имеете пользовательских настроек, этот каталог будет $HOME/.cargo/bin. Убедитесь, что этот каталог присутствует в вашем $PATH, чтобы иметь возможность запускать программы, установленные с помощью cargo install.

Например, в Главе 12 мы упоминали, что существует реализация инструмента grep на Rust под названием ripgrep для поиска по файлам. Чтобы установить ripgrep, выполните следующую команду:

$ cargo install ripgrep
    Updating crates.io index
  Downloaded ripgrep v14.1.1
  Downloaded 1 crate (213.6 KB) in 0.40s
  Installing ripgrep v14.1.1
--snip--
   Compiling grep v0.3.2
    Finished `release` profile [optimized + debuginfo] target(s) in 6.73s
  Installing ~/.cargo/bin/rg
   Installed package `ripgrep v14.1.1` (executable `rg`)

Предпоследняя строка вывода показывает расположение и имя установленного бинарного файла, которым в случае ripgrep является rg. Если каталог установки, как упоминалось ранее, находится в вашем $PATH, вы затем сможете запустить rg --help и начать использовать более быстрый инструмент на Rust для поиска по файлам!

Расширение Cargo пользовательскими командами

Cargo спроектирован так, чтобы вы могли расширять его новыми подкомандами без необходимости его модифицировать. Если исполняемый файл в вашем $PATH называется cargo-something, вы можете запустить его как подкоманду Cargo, выполнив cargo something. Такие пользовательские команды также отображаются при запуске cargo --list. Возможность использовать cargo install для установки расширений и последующего запуска их точно так же, как встроенные инструменты Cargo — это невероятно удобное преимущество дизайна Cargo!

Краткое содержание

Обмен кодом через Cargo и crates.io — это часть того, что делает экосистему Rust полезной для множества различных задач. Стандартная библиотека Rust небольшая и стабильная, но крейты легко делиться, использовать и улучшать по графику, независимому от графика развития языка. Не стесняйтесь делиться на crates.io кодом, который полезен вам; скорее всего, он будет полезен и кому-то другому!

Умные указатели

Указатель — это общее понятие для переменной, содержащей адрес в памяти. Этот адрес указывает на, или “указывает на”, некоторые другие данные. Наиболее распространённым видом указателя в Rust является ссылка, о которой вы узнали в Главе 4. Ссылки обозначаются символом & и заимствуют значение, на которое указывают. У них нет никаких особых возможностей, кроме ссылки на данные, и они не имеют накладных расходов.

Умные указатели, с другой стороны, — это структуры данных, которые действуют как указатель, но также имеют дополнительную метаданные и возможности. Концепция умных указателей не уникальна для Rust: умные указатели появились в C++ и существуют в других языках. В Rust есть множество умных указателей, определённых в стандартной библиотеке, которые предоставляют функциональность, выходящую за рамки возможностей ссылок. Чтобы изучить общую концепцию, мы рассмотрим несколько различных примеров умных указателей, включая типаж управления счётчиком ссылок. Этот указатель позволяет данным иметь нескольких владельцев, отслеживая количество владельцев и очищая данные, когда владельцев не остаётся.

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

Хотя мы не называли их таковыми в то время, мы уже встречали несколько умных указателей в этой книге, включая String и Vec<T> в Главе 8. Оба этих типа считаются умными указателями, потому что они владеют некоторой памятью и позволяют вам управлять ею. Они также имеют метаданные и дополнительные возможности или гарантии. String, например, хранит свою ёмкость как метаданные и имеет дополнительную возможность гарантировать, что его данные всегда будут действительными UTF-8.

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

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

  • Box<T>, для размещения значений в куче
  • Rc<T>, типаж управления счётчиком ссылок, который позволяет множественное владение
  • Ref<T> и RefMut<T>, доступные через RefCell<T>, тип, который обеспечивает соблюдение правил заимствования во время выполнения, а не на этапе компиляции

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

Давайте начнём!

Использование Box<T> для работы с данными в куче

Самый простой умный указатель — это Box, тип которого обозначается как Box<T>. Box позволяет хранить данные в куче, а не в стеке. То, что остаётся в стеке, — это указатель на данные в куче. Обратитесь к Главе 4, чтобы повторить разницу между стеком и кучей.

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

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

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

Использование Box<T> для хранения данных в куче

Прежде чем обсуждать использование Box<T> для хранения в куче, мы рассмотрим синтаксис и взаимодействие со значениями, хранящимися внутри Box<T>.

Листинг 15-1 показывает, как использовать Box для хранения значения i32 в куче.

Filename: src/main.rs
fn main() {
    let b = Box::new(5);
    println!("b = {b}");
}
Listing 15-1: Хранение значения i32 в куче с помощью box

Мы определяем переменную b со значением Box, указывающим на значение 5, размещённое в куче. Эта программа выведет b = 5; в этом случае мы можем получить доступ к данным в Box так же, как если бы они были в стеке. Как и любое владеющее значение, когда Box выходит из области видимости, как b в конце main, он будет освобождён. Освобождение происходит как для Box (хранящегося в стеке), так и для данных, на которые он указывает (хранящихся в куче).

Размещение одного значения в куче не очень полезно, поэтому вы не будете часто использовать Box таким образом. Хранение значений, таких как один i32, в стеке, где они хранятся по умолчанию, более уместно в большинстве ситуаций. Давайте рассмотрим случай, где Box позволяют нам определить типы, которые мы не могли бы определить без Box.

Включение рекурсивных типов с помощью Box

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

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

Дополнительная информация о списке cons

Список cons — это структура данных, происходящая из языка Lisp и его диалектов, состоит из вложенных пар и является версией связного списка в Lisp. Его название происходит от функции cons (сокращение от construct function) в Lisp, которая создаёт новую пару из двух аргументов. Вызывая cons на паре, состоящей из значения и другой пары, мы можем создавать списки cons из рекурсивных пар.

Например, вот псевдокодное представление списка cons, содержащего список 1, 2, 3 с каждой парой в скобках:

(1, (2, (3, Nil)))

Каждый элемент в списке cons содержит два элемента: значение текущего элемента и следующий элемент. Последний элемент в списке содержит только значение Nil без следующего элемента. Список cons создаётся рекурсивным вызовом функции cons. Каноническое имя для обозначения базового случая рекурсии — Nil. Обратите внимание, что это не то же самое, что концепция “null” или “nil”, обсуждавшаяся в Главе 6, которая является недопустимым или отсутствующим значением.

Список cons не является часто используемой структурой данных в Rust. В большинстве случаев, когда у вас есть список элементов в Rust, Vec<T> — лучший выбор. Другие, более сложные рекурсивные типы данных полезны в различных ситуациях, но начиная со списка cons в этой главе, мы можем исследовать, как Box позволяют определить рекурсивный тип данных без больших отвлечений.

Листинг 15-2 содержит определение enum для списка cons. Обратите внимание, что этот код пока не скомпилируется, потому что тип List не имеет известного размера, что мы и продемонстрируем.

Filename: src/main.rs
enum List {
    Cons(i32, List),
    Nil,
}

fn main() {}
Listing 15-2: Первая попытка определить enum для представления структуры данных списка cons значений i32

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

Использование типа List для хранения списка 1, 2, 3 будет выглядеть как код в Листинге 15-3.

Filename: src/main.rs
enum List {
    Cons(i32, List),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}
Listing 15-3: Использование enum List для хранения списка 1, 2, 3

Первое значение Cons содержит 1 и другое значение List. Это значение List является ещё одним значением Cons, которое содержит 2 и другое значение List. Это значение List является ещё одним значением Cons, которое содержит 3 и значение List, которое наконец является Nil, нерекурсивным вариантом, обозначающим конец списка.

Если мы попытаемся скомпилировать код в Листинге 15-3, мы получим ошибку, показанную в Листинге 15-4.

Filename: output.txt
$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0072]: recursive type `List` has infinite size
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
2 |     Cons(i32, List),
  |               ---- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

error[E0391]: cycle detected when computing when `List` needs drop
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
  |
  = note: ...which immediately requires computing when `List` needs drop again
  = note: cycle used when computing whether `List` needs drop
  = note: see https://rustc-dev-guide.rust-lang.org/overview.html#queries and https://rustc-dev-guide.rust-lang.org/query.html for more information

Some errors have detailed explanations: E0072, E0391.
For more information about an error, try `rustc --explain E0072`.
error: could not compile `cons-list` (bin "cons-list") due to 2 previous errors
Listing 15-4: Ошибка, которую мы получаем при попытке определить рекурсивный enum

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

Вычисление размера нерекурсивного типа

Вспомните enum Message, который мы определили в Листинге 6-2, когда обсуждали определения enum в Главе 6:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}

Чтобы определить, сколько места выделить для значения Message, Rust перебирает все варианты, чтобы увидеть, какой вариант требует больше всего места. Rust видит, что Message::Quit не требует места, Message::Move требует достаточно места для хранения двух значений i32, и так далее. Поскольку будет использован только один вариант, максимальное место, которое потребуется значению Message, — это место, необходимое для хранения самого большого из его вариантов.

Сравните это с тем, что происходит, когда Rust пытается определить, сколько места нужно рекурсивному типу, такому как enum List в Листинге 15-2. Компилятор начинает с варианта Cons, который содержит значение типа i32 и значение типа List. Следовательно, Cons требует пространства, равного размеру i32 плюс размер List. Чтобы выяснить, сколько памяти нужно типу List, компилятор смотрит на варианты, начиная с варианта Cons. Вариант Cons содержит значение типа i32 и значение типа List, и этот процесс продолжается бесконечно, как показано на Рисунке 15-1.

An infinite Cons list

Рисунок 15-1: Бесконечный List, состоящий из бесконечных вариантов Cons

Использование Box<T> для получения рекурсивного типа с известным размером

Поскольку Rust не может определить, сколько места выделить для рекурсивно определённых типов, компилятор выдаёт ошибку с этим полезным предложением:

help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

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

Поскольку Box<T> является указателем, Rust всегда знает, сколько места нужно Box<T>: размер указателя не меняется в зависимости от количества данных, на которые он указывает. Это означает, что мы можем поместить Box<T> внутрь варианта Cons вместо другого значения List напрямую. Box<T> будет указывать на следующее значение List, которое будет в куче, а не внутри варианта Cons. Концептуально мы всё ещё имеем список, созданный из списков, содержащих другие списки, но эта реализация теперь больше похожа на размещение элементов рядом друг с другом, а не один внутри другого.

Мы можем изменить определение enum List в Листинге 15-2 и использование List в Листинге 15-3 на код в Листинге 15-5, который скомпилируется.

Filename: src/main.rs
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}
Listing 15-5: Определение List, использующее Box<T> для получения известного размера

Вариант Cons требует размера i32 плюс пространство для хранения данных указателя Box. Вариант Nil не хранит значений, поэтому ему нужно меньше места, чем варианту Cons. Теперь мы знаем, что любое значение List займёт размер i32 плюс размер данных указателя Box. Используя Box, мы разорвали бесконечную рекурсивную цепочку, поэтому компилятор может определить размер, необходимый для хранения значения List. Рисунок 15-2 показывает, как выглядит вариант Cons теперь.

A finite Cons list

Рисунок 15-2: List, который не бесконечного размера, потому что Cons содержит Box

Box предоставляет только перенаправление и выделение в куче; у него нет других специальных возможностей, таких как те, которые мы увидим с другими типами умных указателей. Он также не имеет накладных расходов на производительность, которые влекут эти специальные возможности, поэтому он может быть полезен в случаях, таких как список cons, где перенаправление — это единственная необходимая функция. Мы рассмотрим больше случаев использования Box в Главе 18.

Тип Box<T> является умным указателем, потому что он реализует типаж Deref, который позволяет значениям Box<T> обращаться как ссылкам. Когда значение Box<T> выходит из области видимости, данные в куче, на которые указывает Box, также очищаются благодаря реализации типажа Drop. Эти два типажа будут ещё более важны для функциональности, предоставляемой другими типами умных указателей, которые мы обсудим в остальной части этой главы. Давайте изучим эти два типажа более подробно.

Использование умных указателей как обычных ссылок с помощью Deref

Реализация типажа Deref позволяет вам настроить поведение оператора разыменования * (не путать с оператором умножения или glob-оператором). Реализовав Deref таким образом, чтобы умный указатель можно было использовать как обычную ссылку, вы сможете писать код, работающий со ссылками, и применять этот код также к умным указателям.

Сначала рассмотрим, как оператор разыменования работает с обычными ссылками. Затем попробуем определить собственный тип, который ведёт себя как Box<T>, и посмотрим, почему оператор разыменования не работает с нашим новым типом как со ссылкой. Мы изучим, как реализация типажа Deref позволяет умным указателям работать подобно ссылкам. Затем рассмотрим возможность Rust — неявное преобразование через Deref (deref coercion) и то, как она позволяет работать как со ссылками, так и с умными указателями.

Примечание: Существует одно большое различие между типом MyBox<T>, который мы собираемся создать, и реальным Box<T>: наша версия не будет хранить свои данные в куче. В этом примере мы сосредоточены на Deref, поэтому то, где на самом деле хранятся данные, менее важно, чем поведение, подобное указателю.

Следование по ссылке к значению

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

Filename: src/main.rs
fn main() {
    let x = 5;
    let y = &x;

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listing 15-6: Использование оператора разыменования для следования по ссылке к значению i32

Переменная x содержит значение i32 5. Мы присваиваем y ссылку на x. Мы можем утверждать, что x равен 5. Однако, если мы хотим сделать утверждение о значении в y, мы должны использовать *y, чтобы пройти по ссылке к значению, на которое она указывает (отсюда разыменование), чтобы компилятор мог сравнить фактическое значение. После разыменования y мы получаем доступ к целочисленному значению, на которое указывает y, и можем сравнить его с 5.

Если бы мы попытались написать assert_eq!(5, y); вместо этого, мы получили бы следующую ошибку компиляции:

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0277]: can't compare `{integer}` with `&{integer}`
 --> src/main.rs:6:5
  |
6 |     assert_eq!(5, y);
  |     ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
  |
  = help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
  = note: this error originates in the macro `assert_eq` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0277`.
error: could not compile `deref-example` (bin "deref-example") due to 1 previous error

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

Использование Box<T> как ссылки

Мы можем переписать код из Листинга 15-6, чтобы использовать Box<T> вместо ссылки; оператор разыменования, применённый к Box<T> в Листинге 15-7, функционирует так же, как оператор разыменования, применённый к ссылке в Листинге 15-6:

Filename: src/main.rs
fn main() {
    let x = 5;
    let y = Box::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listing 15-7: Использование оператора разыменования на Box<i32>

Основное различие между Листингом 15-7 и Листингом 15-6 заключается в том, что здесь мы устанавливаем y как экземпляр бокса, указывающего на скопированное значение x, а не как ссылку, указывающую на значение x. В последнем утверждении мы можем использовать оператор разыменования, чтобы пройти по указателю бокса так же, как мы это делали, когда y был ссылкой. Далее мы исследуем, что особенного в Box<T>, что позволяет нам использовать оператор разыменования, определив собственный тип.

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

Давайте создадим умный указатель, похожий на тип Box<T> из стандартной библиотеки, чтобы на практике увидеть, как умные указатели по умолчанию ведут себя иначе, чем ссылки. Затем мы посмотрим, как добавить возможность использования оператора разыменования.

Тип Box<T> в конечном итоге определён как кортежный структ с одним элементом, поэтому Листинг 15-8 определяет тип MyBox<T> таким же образом. Мы также определим функцию new, чтобы соответствовать функции new, определённой для Box<T>.

Filename: src/main.rs
struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {}
Listing 15-8: Определение типа MyBox<T>

Мы определяем структуру с именем MyBox и объявляем обобщённый параметр T, потому что хотим, чтобы наш тип мог хранить значения любого типа. Тип MyBox — это кортежный структ с одним элементом типа T. Функция MyBox::new принимает один параметр типа T и возвращает экземпляр MyBox, который хранит переданное значение.

Попробуем добавить функцию main из Листинга 15-7 в Листинг 15-8 и изменить её для использования типа MyBox<T>, который мы определили, вместо Box<T>. Код в Листинге 15-9 не скомпилируется, потому что Rust не знает, как разыменовать MyBox.

Filename: src/main.rs
struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listing 15-9: Попытка использовать MyBox<T> так же, как мы использовали ссылки и Box<T>

Вот resultant ошибка компиляции:

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
  --> src/main.rs:14:19
   |
14 |     assert_eq!(5, *y);
   |                   ^^

For more information about this error, try `rustc --explain E0614`.
error: could not compile `deref-example` (bin "deref-example") due to 1 previous error

Наш тип MyBox<T> не может быть разыменован, потому что мы не реализовали эту возможность для нашего типа. Чтобы включить разыменование с помощью оператора *, мы реализуем типаж Deref.

Реализация типажа Deref

Как обсуждалось в разделе “Реализация типажа для типа” в Главе 10, чтобы реализовать типаж, нам нужно предоставить реализации для обязательных методов типажа. Типаж Deref, предоставляемый стандартной библиотекой, требует от нас реализовать один метод с именем deref, который заимствует self и возвращает ссылку на внутренние данные. Листинг 15-10 содержит реализацию Deref для добавления в определение MyBox<T>.

Filename: src/main.rs
use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listing 15-10: Реализация Deref для MyBox<T>

Синтаксис type Target = T; определяет ассоциированный тип для использования типажом Deref. Ассоциированные типы — это немного другой способ объявления обобщённого параметра, но пока вам не нужно об этом беспокоиться; мы подробнее разберём их в Главе 20.

Мы заполняем тело метода deref выражением &self.0, чтобы deref возвращал ссылку на значение, к которому мы хотим получить доступ с помощью оператора *; вспомните из раздела “Использование кортежных структур без именованных полей для создания разных типов” в Главе 5, что .0 обращается к первому значению в кортежной структуре. Функция main в Листинге 15-9, которая вызывает * на значении MyBox<T>, теперь компилируется, и утверждения проходят!

Без типажа Deref компилятор может разыменовывать только ссылки &. Метод deref даёт компилятору возможность взять значение любого типа, реализующего Deref, и вызвать метод deref, чтобы получить ссылку &, которую он умеет разыменовывать.

Когда мы вводим *y в Листинге 15-9, за кулисами Rust фактически выполняет этот код:

*(y.deref())

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

Причина, по которой метод deref возвращает ссылку на значение, и почему обычное разыменование за скобками в *(y.deref()) всё ещё необходимо, связана с системой владения. Если бы метод deref возвращал значение напрямую, а не ссылку на значение, значение было бы перемещено из self. Мы не хотим брать владение внутренним значением внутри MyBox<T> в этом случае или в большинстве случаев, когда мы используем оператор разыменования.

Обратите внимание, что оператор * заменяется вызовом метода deref, а затем вызовом оператора * только один раз каждый раз, когда мы используем * в нашем коде. Поскольку подстановка оператора * не рекурсирует бесконечно, мы в итоге получаем данные типа i32, что соответствует 5 в assert_eq! в Листинге 15-9.

Неявное преобразование через Deref в функциях и методах

Неявное преобразование через Deref (deref coercion) преобразует ссылку на тип, реализующий типаж Deref, в ссылку на другой тип. Например, неявное преобразование через Deref может преобразовать &String в &str, потому что String реализует типаж Deref так, что возвращает &str. Неявное преобразование через Deref — это удобство, которое Rust выполняет для аргументов функций и методов, и оно работает только для типов, реализующих типаж Deref. Оно происходит автоматически, когда мы передаём ссылку на значение определённого типа в качестве аргумента функции или метода, который не соответствует типу параметра в определении функции или метода. Последовательность вызовов метода deref преобразует предоставленный тип в тип, необходимый параметру.

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

Чтобы увидеть неявное преобразование через Deref в действии, давайте используем тип MyBox<T>, который мы определили в Листинге 15-8, а также реализацию Deref, которую мы добавили в Листинге 15-10. Листинг 15-11 показывает определение функции, которая имеет параметр строкового среза.

Filename: src/main.rs
fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {}
Listing 15-11: Функция hello, которая имеет параметр name типа &str

Мы можем вызвать функцию hello со строковым срезом в качестве аргумента, например hello("Rust");. Неявное преобразование через Deref делает возможным вызов hello со ссылкой на значение типа MyBox<String>, как показано в Листинге 15-12.

Filename: src/main.rs
use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&m);
}
Listing 15-12: Вызов hello со ссылкой на значение MyBox<String>, что работает благодаря неявному преобразованию через Deref

Здесь мы вызываем функцию hello с аргументом &m, который является ссылкой на значение MyBox<String>. Поскольку мы реализовали типаж Deref для MyBox<T> в Листинге 15-10, Rust может преобразовать &MyBox<String> в &String, вызвав deref. Стандартная библиотека предоставляет реализацию Deref для String, которая возвращает строковый срез, и это указано в документации API для Deref. Rust вызывает deref ещё раз, чтобы преобразовать &String в &str, что соответствует определению функции hello.

Если бы Rust не реализовывал неявное преобразование через Deref, нам пришлось бы написать код из Листинга 15-13 вместо кода из Листинга 15-12, чтобы вызвать hello со значением типа &MyBox<String>.

Filename: src/main.rs
use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&(*m)[..]);
}
Listing 15-13: Код, который нам пришлось бы написать, если бы у Rust не было неявного преобразования через Deref

(*m) разыменовывает MyBox<String> в String. Затем & и [..] берут строковый срез из String, который равен всей строке, чтобы соответствовать сигнатуре hello. Этот код без неявных преобразований через Deref сложнее для чтения, написания и понимания из-за всех этих символов. Неявное преобразование через Deref позволяет Rust автоматически обрабатывать эти преобразования для нас.

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

Взаимодействие неявного преобразования через Deref с изменяемостью

Подобно тому, как вы используете типаж Deref для переопределения оператора * на неизменяемых ссылках, вы можете использовать типаж DerefMut для переопределения оператора * на изменяемых ссылках.

Rust выполняет неявное преобразование через Deref, когда находит типы и реализации типажей в трёх случаях:

  1. Из &T в &U, когда T: Deref<Target=U>
  2. Из &mut T в &mut U, когда T: DerefMut<Target=U>
  3. Из &mut T в &U, когда T: Deref<Target=U>

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

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

Выполнение кода при очистке с помощью типажа Drop

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

Мы представляем Drop в контексте умных указателей, потому что функциональность типажа Drop почти всегда используется при реализации умного указателя. Например, когда Box<T> удаляется, он освобождает пространство в куче, на которое указывает бокс.

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

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

Листинг 15-14 показывает структуру CustomSmartPointer, единственная пользовательская функциональность которой — вывод Dropping CustomSmartPointer! при выходе экземпляра из области видимости, чтобы показать, когда Rust запускает метод drop.

Filename: src/main.rs
struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("my stuff"),
    };
    let d = CustomSmartPointer {
        data: String::from("other stuff"),
    };
    println!("CustomSmartPointers created.");
}
Listing 15-14: Структура CustomSmartPointer, реализующая типаж Drop, в которую мы бы поместили наш код очистки

Типаж Drop включён в прелюдию, поэтому нам не нужно импортировать его в область видимости. Мы реализуем типаж Drop для CustomSmartPointer и предоставим реализацию для метода drop, который вызывает println!. Тело метода drop — это место, где вы бы разместили любую логику, которую хотите выполнить при выходе экземпляра вашего типа из области видимости. Здесь мы выводим некоторый текст для наглядной демонстрации того, когда Rust вызовет drop.

В main мы создаём два экземпляра CustomSmartPointer и затем выводим CustomSmartPointers created. В конце main наши экземпляры CustomSmartPointer выйдут из области видимости, и Rust вызовет код, который мы поместили в метод drop, выведя наше итоговое сообщение. Обратите внимание, что нам не нужно было явно вызывать метод drop.

Когда мы запускаем эту программу, мы увидим следующий вывод:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running `target/debug/drop-example`
CustomSmartPointers created.
Dropping CustomSmartPointer with data `other stuff`!
Dropping CustomSmartPointer with data `my stuff`!

Rust автоматически вызвал drop для нас, когда наши экземпляры вышли из области видимости, выполнив указанный нами код. Переменные удаляются в обратном порядке их создания, поэтому d был удалён перед c. Цель этого примера — дать вам наглядное представление о том, как работает метод drop; обычно вы бы указали код очистки, который требуется вашему типу, а не сообщение для печати.

К сожалению, отключить автоматическую функциональность drop непросто. Отключение drop обычно не требуется; вся суть типажа Drop в том, что он обрабатывается автоматически. Однако иногда вы можете захотеть очистить значение раньше. One example is when using smart pointers that manage locks: you might want to force the drop method that releases the lock so that other code in the same scope can acquire the lock. Rust не позволяет вам вручную вызывать метод drop типажа Drop; вместо этого вам нужно вызвать функцию std::mem::drop, предоставленную стандартной библиотекой, если вы хотите принудительно удалить значение до конца его области видимости.

Если мы попытаемся вручную вызвать метод drop типажа Drop, изменив функцию main из листинга 15-14, как показано в листинге 15-15, мы получим ошибку компиляции.

Filename: src/main.rs
struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("some data"),
    };
    println!("CustomSmartPointer created.");
    c.drop();
    println!("CustomSmartPointer dropped before the end of main.");
}
Listing 15-15: Попытка вручную вызвать метод drop из типажа Drop для ранней очистки

При попытке скомпилировать этот код мы получим следующую ошибку:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
error[E0040]: explicit use of destructor method
  --> src/main.rs:16:7
   |
16 |     c.drop();
   |       ^^^^ explicit destructor calls not allowed
   |
help: consider using `drop` function
   |
16 |     drop(c);
   |     +++++ ~

For more information about this error, try `rustc --explain E0040`.
error: could not compile `drop-example` (bin "drop-example") due to 1 previous error

Это сообщение об ошибке гласит, что нам не разрешено явно вызывать drop. Сообщение об ошибке использует термин деструктор, который является общим термином программирования для функции, очищающей экземпляр. Деструктор аналогичен конструктору, который создаёт экземпляр. Функция drop в Rust — это один particular destructor.

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

Мы не можем отключить автоматическую вставку drop при выходе значения из области видимости и не можем вызвать метод drop явно. Поэтому, если нам нужно принудительно очистить значение раньше, мы используем функцию std::mem::drop.

Функция std::mem::drop отличается от метода drop в типаже Drop. Мы вызываем её, передавая в качестве аргумента значение, которое хотим принудительно удалить. Функция находится в прелюдии, поэтому мы можем изменить main в листинге 15-15, чтобы вызвать функцию drop, как показано в листинге 15-16.

Filename: src/main.rs
struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("some data"),
    };
    println!("CustomSmartPointer created.");
    drop(c);
    println!("CustomSmartPointer dropped before the end of main.");
}
Listing 15-16: Вызов std::mem::drop для явного удаления значения до выхода из области видимости

Запуск этого кода выведет следующее:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/drop-example`
CustomSmartPointer created.
Dropping CustomSmartPointer with data `some data`!
CustomSmartPointer dropped before the end of main.

Текст Dropping CustomSmartPointer with data `some data`! выводится между CustomSmartPointer created. и CustomSmartPointer dropped before the end of main., показывая, что код метода drop вызывается для удаления c в этот момент.

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

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

Теперь, когда мы рассмотрели Box<T> и некоторые характеристики умных указателей, давайте посмотрим на несколько других умных указателей, определённых в стандартной библиотеке.

Rc<T>, умный указатель с подсчётом ссылок

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

Включить несколько владельцев нужно явно, используя тип Rust Rc<T>, который является сокращением от reference counting (подсчёт ссылок). Тип Rc<T> отслеживает количество ссылок на значение, чтобы определить, используется ли оно ещё. Если ссылок на значение не осталось, его можно удалить, не оставляя недействительных ссылок.

Представьте Rc<T> как телевизор в гостиной. Когда один человек заходит посмотреть телевизор, он включает его. Другие могут зайти в комнату и смотреть телевизор. Когда последний человек выходит из комнаты, он выключает телевизор, потому что он больше не используется. Если бы кто-то выключил телевизор, пока другие ещё смотрят, это вызвало бы возмущение оставшихся зрителей!

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

Обратите внимание, что Rc<T> предназначен только для однопоточных сценариев. Когда мы обсудим конкурентность в главе 16, мы рассмотрим, как выполнять подсчёт ссылок в многопоточных программах.

Использование Rc<T> для разделения данных

Вернёмся к нашему примеру списка cons в листинге 15-5. Напомним, что мы определили его с помощью Box<T>. На этот раз мы создадим два списка, которые оба разделяют владение третьим списком. Концептуально это похоже на рисунок 15-3.

Два списка, разделяющие владение третьим списком

Рисунок 15-3: Два списка, b и c, разделяющие владение третьим списком, a

Мы создадим список a, содержащий 5 и затем 10. Затем создадим ещё два списка: b, начинающийся с 3, и c, начинающийся с 4. И список b, и список c затем продолжат первый список a, содержащий 5 и 10. Другими словами, оба списка разделят первый список, содержащий 5 и 10.

Попытка реализовать этот сценарий, используя наше определение List с Box<T>, не сработает, как показано в листинге 15-17:

Filename: src/main.rs
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
    let b = Cons(3, Box::new(a));
    let c = Cons(4, Box::new(a));
}
Listing 15-17: Демонстрация того, что двум спискам с Box<T> не разрешено разделять владение третьим списком

При компиляции этого кода мы получаем следующую ошибку:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0382]: use of moved value: `a`
  --> src/main.rs:11:30
   |
9  |     let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
   |         - move occurs because `a` has type `List`, which does not implement the `Copy` trait
10 |     let b = Cons(3, Box::new(a));
   |                              - value moved here
11 |     let c = Cons(4, Box::new(a));
   |                              ^ value used here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `cons-list` (bin "cons-list") due to 1 previous error

Варианты Cons владеют хранящимися в них данными, поэтому при создании списка b значение a перемещается в b, и b владеет a. Затем, когда мы пытаемся снова использовать a при создании c, нам это не разрешено, потому что a было перемещено.

Мы могли бы изменить определение Cons так, чтобы оно хранило ссылки вместо значений, но тогда нам пришлось бы указать параметры времени жизни. Указав параметры времени жизни, мы бы определяли, что каждый элемент списка будет жить не меньше, чем весь список. Это верно для элементов и списков в листинге 15-17, но не для каждого сценария.

Вместо этого мы изменим наше определение List на использование Rc<T> вместо Box<T>, как показано в листинге 15-18. Каждый вариант Cons теперь будет хранить значение и Rc<T>, указывающий на List. При создании b вместо того, чтобы принимать владение a, мы клонируем Rc<List>, который хранится в a, тем самым увеличивая количество ссылок с одного до двух и позволяя a и b разделять владение данными в этом Rc<List>. Мы также клонируем a при создании c, увеличивая количество ссылок с двух до трёх. Каждый раз при вызове Rc::clone количество ссылок на данные внутри Rc<List> увеличивается, и данные не будут удалены, пока на них не останется нулевых ссылок.

Filename: src/main.rs
enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    let b = Cons(3, Rc::clone(&a));
    let c = Cons(4, Rc::clone(&a));
}
Listing 15-18: Определение List, использующее Rc<T>

Нам нужно добавить оператор use, чтобыBring Rc<T> в область видимости, потому что он не находится в прелюдии. В main мы создаём список, содержащий 5 и 10, и сохраняем его в новом Rc<List> в a. Затем при создании b и c мы вызываем функцию Rc::clone и передаём ссылку на Rc<List> в a в качестве аргумента.

Мы могли бы вызвать a.clone() вместо Rc::clone(&a), но соглашение Rust в этом случае — использовать Rc::clone. Реализация Rc::clone не создаёт глубокую копию всех данных, как это делают реализации clone у большинства типов. Вызов Rc::clone только увеличивает счётчик ссылок, что не требует много времени. Глубокие копии данных могут занять много времени. Используя Rc::clone для подсчёта ссылок, мы можем визуально различать виды клонов, создающих глубокие копии, и виды клонов, увеличивающих счётчик ссылок. При поиске проблем с производительностью в коде нам нужно рассматривать только глубокие копии и игнорировать вызовы Rc::clone.

Клонирование Rc<T> увеличивает счётчик ссылок

Изменим наш рабочий пример из листинга 15-18, чтобы мы могли видеть, как меняются счётчики ссылок при создании и удалении ссылок на Rc<List> в a.

В листинге 15-19 мы изменим main так, чтобы у списка c была внутренняя область видимости; тогда мы увидим, как меняется счётчик ссылок, когда c выходит из области видимости.

Filename: src/main.rs
enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    println!("count after creating a = {}", Rc::strong_count(&a));
    let b = Cons(3, Rc::clone(&a));
    println!("count after creating b = {}", Rc::strong_count(&a));
    {
        let c = Cons(4, Rc::clone(&a));
        println!("count after creating c = {}", Rc::strong_count(&a));
    }
    println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}
Listing 15-19: Вывод счётчика ссылок

В каждой точке программы, где счётчик ссылок меняется, мы выводим счётчик ссылок, получая его вызовом функции Rc::strong_count. Эта функция называется strong_count (сильный счётчик), а не просто count, потому что тип Rc<T> также имеет weak_count (слабый счётчик); мы увидим, для чего используется weak_count, в разделе “Предотвращение циклов ссылок с помощью Weak<T>.

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

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.45s
     Running `target/debug/cons-list`
count after creating a = 1
count after creating b = 2
count after creating c = 3
count after c goes out of scope = 2

Мы видим, что у Rc<List> в a начальный счётчик ссылок равен 1; затем каждый раз при вызове clone счётчик увеличивается на 1. Когда c выходит из области видимости, счётчик уменьшается на 1. Нам не нужно вызывать функцию для уменьшения счётчика ссылок, как мы вызываем Rc::clone для его увеличения: реализация типажа Drop автоматически уменьшает счётчик ссылок, когда значение Rc<T> выходит из области видимости.

То, что мы не видим в этом примере, — это то, что когда b, а затем и a выходят из области видимости в конце main, счётчик становится 0, и Rc<List> полностью удаляется. Использование Rc<T> позволяет одному значению иметь нескольких владельцев, и счётчик гарантирует, что значение остаётся действительным, пока любой из владельцев всё ещё существует.

Через неизменяемые ссылки Rc<T> позволяет вам разделять данные между несколькими частями программы только для чтения. Если бы Rc<T> позволял иметь и несколько изменяемых ссылок, это могло бы нарушить одно из правил заимствования, обсуждавшихся в главе 4: несколько изменяемых заимствований одного и того же места могут вызвать гонки данных и несогласованность. Но возможность изменять данные очень полезна! В следующем разделе мы обсудим паттерн внутренней изменяемости и тип RefCell<T>, который можно использовать вместе с Rc<T> для обхода этого ограничения на неизменяемость.

RefCell<T> и паттерн внутренней изменяемости

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

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

Давайте изучим эту концепцию на примере типа RefCell<T>, который следует паттерну внутренней изменяемости.

Принудительное соблюдение правил заимствования во время выполнения с помощью RefCell<T>

В отличие от Rc<T>, тип RefCell<T> представляет собой единоличное владение данными, которые он хранит. Так в чём же разница между RefCell<T> и типом вроде Box<T>? Вспомните правила заимствования из Главы 4:

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

Со ссылками и Box<T> инварианты правил заимствования проверяются на этапе компиляции. С RefCell<T> эти инварианты проверяются во время выполнения. Со ссылками, если вы нарушите эти правила, вы получите ошибку компиляции. С RefCell<T>, если вы нарушите эти правила, ваша программа аварийно завершится (panic).

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

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

Поскольку некоторый анализ невозможен, если компилятор Rust не может быть уверен, что код соответствует правилам владения, он может отклонить правильную программу; таким образом, он консервативен. Если бы Rust принял неправильную программу, пользователи не смогли бы доверять гарантиям Rust. Однако, если Rust отклоняет правильную программу, программисту будет неудобно, но ничего катастрофического не произойдёт. Тип RefCell<T> полезен, когда вы уверены, что ваш код следует правилам заимствования, но компилятор не может понять и гарантировать это.

Подобно Rc<T>, RefCell<T> предназначен только для использования в однопоточных сценариях и выдаст ошибку компиляции, если вы попытаетесь использовать его в многопоточном контексте. Мы поговорим о том, как получить функциональность RefCell<T> в многопоточной программе, в Главе 16.

Вот краткое резюме причин выбора Box<T>, Rc<T> или RefCell<T>:

  • Rc<T> позволяет нескольким владельцам одних и тех же данных; Box<T> и RefCell<T> имеют единоличных владельцев.
  • Box<T> позволяет неизменяемые или изменяемые заимствования, проверяемые на этапе компиляции; Rc<T> позволяет только неизменяемые заимствования, проверяемые на этапе компиляции; RefCell<T> позволяет неизменяемые или изменяемые заимствования, проверяемые во время выполнения.
  • Поскольку RefCell<T> позволяет изменяемые заимствования, проверяемые во время выполнения, вы можете изменять значение внутри RefCell<T>, даже когда сам RefCell<T> является неизменяемым.

Изменение значения внутри неизменяемого значения — это паттерн внутренней изменяемости. Давайте рассмотрим ситуацию, в которой внутренняя изменяемость полезна, и изучим, как это возможно.

Внутренняя изменяемость: изменяемое заимствование для неизменяемого значения

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

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

Если бы вы попытались скомпилировать этот код, вы получили бы следующую ошибку:

$ cargo run
   Compiling borrowing v0.1.0 (file:///projects/borrowing)
error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable
 --> src/main.rs:3:13
  |
3 |     let y = &mut x;
  |             ^^^^^^ cannot borrow as mutable
  |
help: consider changing this to be mutable
  |
2 |     let mut x = 5;
  |         +++

For more information about this error, try `rustc --explain E0596`.
error: could not compile `borrowing` (bin "borrowing") due to 1 previous error

Однако есть ситуации, в которых было бы полезно, чтобы значение изменяло себя в своих методах, но казалось неизменяемым для другого кода. Код за пределами методов значения не смог бы изменять значение. Использование RefCell<T> — один из способов получить возможность внутренней изменяемости, но RefCell<T> не обходит правила заимствования полностью: проверка заимствования в компиляторе разрешает эту внутреннюю изменяемость, и правила заимствования проверяются во время выполнения вместо этого. Если вы нарушите правила, вы получите panic! вместо ошибки компиляции.

Давайте разберём практический пример, где мы можем использовать RefCell<T> для изменения неизменяемого значения, и посмотрим, почему это полезно.

Сценарий использования внутренней изменяемости: Mock-объекты

Иногда во время тестирования программист использует один тип вместо другого, чтобы наблюдать за определённым поведением и убедиться, что оно реализовано правильно. Этот тип-заменитель называется тестовым дублёром (test double). Подумайте об этом в смысле дублёра трюков в кинематографе, где человек подменяет актёра, чтобы выполнить особенно сложную сцену. Тестовые дублёры подменяют другие типы, когда мы запускаем тесты. Mock-объекты — это конкретные типы тестовых дублёров, которые записывают, что происходит во время теста, чтобы вы могли утверждать, что правильные действия были выполнены.

У Rust нет объектов в том же смысле, что у других языков, и в стандартной библиотеке Rust нет встроенной функциональности mock-объектов, как в некоторых других языках. Однако вы certainly можете создать структуру, которая будет служить теми же целями, что и mock-объект.

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

Наша библиотека будет предоставлять только функциональность отслеживания того, насколько значение близко к максимуму, и какие сообщения должны быть в какие моменты. Приложения, использующие нашу библиотеку, должны будут предоставить механизм для отправки сообщений: приложение может вывести сообщение в интерфейсе, отправить email, отправить текстовое сообщение или сделать что-то ещё. Библиотеке не нужно знать эту деталь. Ей нужно только нечто, реализующее типаж, который мы предоставим, под названием Messenger. Листинг 15-20 показывает код библиотеки.

Filename: src/lib.rs
pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}
Listing 15-20: Библиотека для отслеживания того, насколько значение близко к максимальному, и предупреждения, когда значение достигает определённых уровней

Одна важная часть этого кода в том, что типаж Messenger имеет один метод под названием send, который принимает неизменяемую ссылку на self и текст сообщения. Этот типаж — интерфейс, который наш mock-объект должен реализовать, чтобы его можно было использовать так же, как реальный объект. Другая важная часть в том, что мы хотим протестировать поведение метода set_value на LimitTracker. Мы можем изменить то, что передаём в параметр value, но set_value ничего не возвращает, на что мы могли бы делать утверждения. Мы хотим иметь возможность сказать, что если мы создаём LimitTracker с чем-то, что реализует типаж Messenger, и определённым значением для max, когда мы передаём разные числа для value, messenger получает команду отправить соответствующие сообщения.

Нам нужен mock-объект, который вместо отправки email или текстового сообщения, когда мы вызываем send, будет только отслеживать сообщения, которые ему сказано отправить. Мы можем создать новый экземпляр mock-объекта, создать LimitTracker, который использует mock-объект, вызвать метод set_value на LimitTracker, а затем проверить, что у mock-объекта есть ожидаемые сообщения. Листинг 15-21 показывает попытку реализовать mock-объект для этого, но проверка заимствования этого не позволит.

Filename: src/lib.rs
pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    struct MockMessenger {
        sent_messages: Vec<String>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: vec![],
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.len(), 1);
    }
}
Listing 15-21: Попытка реализовать MockMessenger, которая не разрешена проверкой заимствования

Этот тестовый код определяет структуру MockMessenger, у которой есть поле sent_messages с Vec значений String для отслеживания сообщений, которые ей сказано отправить. Мы также определяем ассоциированную функцию new, чтобы было удобно создавать новые значения MockMessenger, которые начинаются с пустого списка сообщений. Затем мы реализуем типаж Messenger для MockMessenger, чтобы мы могли передать MockMessenger в LimitTracker. В определении метода send мы берём сообщение, переданное в качестве параметра, и сохраняем его в списке sent_messages MockMessenger.

В тесте мы проверяем, что происходит, когда LimitTracker получает команду установить value в нечто, что составляет более 75 процентов от значения max. Сначала мы создаём новый MockMessenger, который начнётся с пустого списка сообщений. Затем мы создаём новый LimitTracker и передаём ему ссылку на новый MockMessenger и значение max равное 100. Мы вызываем метод set_value на LimitTracker со значением 80, что составляет более 75 процентов от 100. Затем мы утверждаем, что список сообщений, который MockMessenger отслеживает, теперь должен содержать одно сообщение.

Однако есть одна проблема с этим тестом, как показано здесь:

$ cargo test
   Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
error[E0596]: cannot borrow `self.sent_messages` as mutable, as it is behind a `&` reference
  --> src/lib.rs:58:13
   |
58 |             self.sent_messages.push(String::from(message));
   |             ^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be borrowed as mutable
   |
help: consider changing this to be a mutable reference in the `impl` method and the `trait` definition
   |
2  ~     fn send(&mut self, msg: &str);
3  | }
...
56 |     impl Messenger for MockMessenger {
57 ~         fn send(&mut self, message: &str) {
   |

For more information about this error, try `rustc --explain E0596`.
error: could not compile `limit-tracker` (lib test) due to 1 previous error

Мы не можем изменить MockMessenger для отслеживания сообщений, потому что метод send принимает неизменяемую ссылку на self. Мы также не можем принять предложение из текста ошибки использовать &mut self как в impl методе, так и в определении типажа. Мы не хотим изменять типаж Messenger только ради тестирования. Вместо этого нам нужно найти способ заставить наш тестовый код работать правильно с нашим существующим дизайном.

Это ситуация, в которой может помочь внутренняя изменяемость! Мы будем хранить sent_messages внутри RefCell<T>, и тогда метод send сможет изменять sent_messages для сохранения сообщений, которые мы видели. Листинг 15-22 показывает, как это выглядит.

Filename: src/lib.rs
pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    struct MockMessenger {
        sent_messages: RefCell<Vec<String>>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: RefCell::new(vec![]),
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.borrow_mut().push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        // --snip--
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
    }
}
Listing 15-22: Использование RefCell<T> для изменения внутреннего значения, пока внешнее значение считается неизменяемым

Поле sent_messages теперь имеет тип RefCell<Vec<String>> вместо Vec<String>. В функции new мы создаём новый экземпляр RefCell<Vec<String>> вокруг пустого вектора.

Для реализации метода send первый параметр по-прежнему является неизменяемым заимствованием self, что соответствует определению типажа. Мы вызываем borrow_mut на RefCell<Vec<String>> в self.sent_messages, чтобы получить изменяемую ссылку на значение внутри RefCell<Vec<String>>, то есть на вектор. Затем мы можем вызвать push на изменяемой ссылке на вектор, чтобы отслеживать сообщения, отправленные во время теста.

Последнее изменение, которое нам нужно сделать, — в утверждении: чтобы увидеть, сколько элементов во внутреннем векторе, мы вызываем borrow на RefCell<Vec<String>>, чтобы получить неизменяемую ссылку на вектор.

Теперь, когда вы увидели, как использовать RefCell<T>, давайте углубимся в то, как он работает!

Отслеживание заимствований во время выполнения с помощью RefCell<T>

При создании неизменяемых и изменяемых ссылок мы используем синтаксис & и &mut соответственно. С RefCell<T> мы используем методы borrow и borrow_mut, которые являются частью безопасного API, принадлежащего RefCell<T>. Метод borrow возвращает тип умного указателя Ref<T>, а borrow_mut возвращает тип умного указателя RefMut<T>. Оба типа реализуют Deref, поэтому мы можем обращаться с ними как с обычными ссылками.

RefCell<T> отслеживает, сколько умных указателей Ref<T> и RefMut<T> в данный момент активны. Каждый раз, когда мы вызываем borrow, RefCell<T> увеличивает счётчик активных неизменяемых заимствований. Когда значение Ref<T> выходит из области видимости, счётчик неизменяемых заимствований уменьшается на 1. Подобно правилам заимствования на этапе компиляции, RefCell<T> позволяет нам иметь много неизменяемых заимствований или одно изменяемое заимствование в любой момент времени.

Если мы попытаемся нарушить эти правила, вместо получения ошибки компиляции, как было бы со ссылками, реализация RefCell<T> вызовет панику во время выполнения. Листинг 15-23 показывает изменение реализации send из Листинга 15-22. Мы сознательно пытаемся создать два изменяемых заимствования, активных в одной области видимости, чтобы проиллюстрировать, что RefCell<T> предотвращает это во время выполнения.

Filename: src/lib.rs
pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    struct MockMessenger {
        sent_messages: RefCell<Vec<String>>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: RefCell::new(vec![]),
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            let mut one_borrow = self.sent_messages.borrow_mut();
            let mut two_borrow = self.sent_messages.borrow_mut();

            one_borrow.push(String::from(message));
            two_borrow.push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
    }
}
Listing 15-23: Создание двух изменяемых ссылок в одной области видимости, чтобы увидеть, что RefCell<T> вызовет панику

Мы создаём переменную one_borrow для умного указателя RefMut<T>, возвращённого из borrow_mut. Затем мы создаём другое изменяемое заимствование таким же образом в переменной two_borrow. Это создаёт две изменяемые ссылки в одной области видимости, что не разрешено. Когда мы запускаем тесты для нашей библиотеки, код в Листинге 15-23 скомпилируется без ошибок, но тест завершится неудачей:

$ cargo test
   Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/limit_tracker-e599811fa246dbde)

running 1 test
test tests::it_sends_an_over_75_percent_warning_message ... FAILED

failures:

---- tests::it_sends_an_over_75_percent_warning_message stdout ----

thread 'tests::it_sends_an_over_75_percent_warning_message' panicked at src/lib.rs:60:53:
already borrowed: BorrowMutError
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::it_sends_an_over_75_percent_warning_message

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Обратите внимание, что код вызвал панику с сообщением already borrowed: BorrowMutError. Вот так RefCell<T> обрабатывает нарушения правил заимствования во время выполнения.

Выбор перехвата ошибок заимствования во время выполнения, а не на этапе компиляции, как мы сделали здесь, означает, что вы потенциально будете находить ошибки в своём коде позже в процессе разработки: возможно, не до тех пор, пока ваш код не будет развёрнут в производстве. Также ваш код понесёт небольшое снижение производительности во время выполнения в результате отслеживания заимствований во время выполнения, а не на этапе компиляции. Однако использование RefCell<T> делает возможным написание mock-объекта, который может изменять себя для отслеживания сообщений, которые он видел, пока вы используете его в контексте, где разрешены только неизменяемые значения. Вы можете использовать RefCell<T> несмотря на его компромиссы, чтобы получить больше функциональности, чем предоставляют обычные ссылки.

Разрешение нескольким владельцам изменяемых данных с помощью Rc<T> и RefCell<T>

Распространённый способ использования RefCell<T> — в комбинации с Rc<T>. Вспомните, что Rc<T> позволяет иметь нескольких владельцев одних и тех же данных, но даёт только неизменяемый доступ к этим данным. Если у вас есть Rc<T>, который хранит RefCell<T>, вы можете получить значение, которое может иметь нескольких владельцев и которое можно изменять!

Например, вспомните пример cons-списка в Листинге 15-18, где мы использовали Rc<T>, чтобы позволить нескольким спискам разделять владение другим списком. Поскольку Rc<T> хранит только неизменяемые значения, мы не можем изменить ни одно из значений в списке после того, как создали их. Давайте добавим RefCell<T> для его способности изменять значения в списках. Листинг 15-24 показывает, что, используя RefCell<T> в определении Cons, мы можем изменить значение, хранящееся во всех списках.

Filename: src/main.rs
#[derive(Debug)]
enum List {
    Cons(Rc<RefCell<i32>>, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

fn main() {
    let value = Rc::new(RefCell::new(5));

    let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));

    let b = Cons(Rc::new(RefCell::new(3)), Rc::clone(&a));
    let c = Cons(Rc::new(RefCell::new(4)), Rc::clone(&a));

    *value.borrow_mut() += 10;

    println!("a after = {a:?}");
    println!("b after = {b:?}");
    println!("c after = {c:?}");
}
Listing 15-24: Использование Rc<RefCell<i32>> для создания List, который мы можем изменять

Мы создаём значение, которое является экземпляром Rc<RefCell<i32>>, и сохраняем его в переменной с именем value, чтобы позже иметь к нему прямой доступ. Затем мы создаём List в a с вариантом Cons, который хранит value. Нам нужно клонировать value, чтобы и a, и value имели владение внутренним значением 5, а не передавали владение из value в a или чтобы a заимствовал из value.

Мы оборачиваем список a в Rc<T>, чтобы когда мы создаём списки b и c, они оба могли ссылаться на a, что мы и сделали в Листинге 15-18.

После того как мы создали списки в a, b и c, мы хотим добавить 10 к значению в value. Мы делаем это, вызывая borrow_mut на value, что использует функцию автоматического разыменования, которую мы обсуждали в Главе 4, чтобы разыменовать Rc<T> до внутреннего значения RefCell<T>. Метод borrow_mut возвращает умный указатель RefMut<T>, и мы используем оператор разыменования на нём и изменяем внутреннее значение.

Когда мы печатаем a, b и c, мы видим, что у всех них изменённое значение 15 вместо 5:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.63s
     Running `target/debug/cons-list`
a after = Cons(RefCell { value: 15 }, Nil)
b after = Cons(RefCell { value: 3 }, Cons(RefCell { value: 15 }, Nil))
c after = Cons(RefCell { value: 4 }, Cons(RefCell { value: 15 }, Nil))

Эта техника довольно интересна! Используя RefCell<T>, мы имеем внешне неизменяемое значение List. Но мы можем использовать методы на RefCell<T>, которые предоставляют доступ к его внутренней изменяемости, чтобы изменять наши данные, когда нам это нужно. Проверка правил заимствования во время выполнения защищает нас от гонок данных, и иногда стоит променять немного скорости на эту гибкость в наших структурах данных. Обратите внимание, что RefCell<T> не работает для многопоточного кода! Mutex<T> — это потокобезопасная версия RefCell<T>, и мы обсудим Mutex<T> в Главе 16.

Циклические ссылки могут приводить к утечке памяти

Гарантии безопасности памяти в Rust затрудняют, но не исключают полностью, случайное создание памяти, которая никогда не освобождается (так называемая утечка памяти). Полное предотвращение утечек памяти не входит в гарантии Rust, что означает, что утечки памяти в Rust безопасны для памяти. Мы можем увидеть, что Rust позволяет утечки памяти, используя Rc<T> и RefCell<T>: можно создать ссылки, где элементы ссылаются друг на друга в цикле. Это создаёт утечки памяти, потому что счётчик ссылок каждого элемента в цикле никогда не достигнет 0, и значения никогда не будут удалены.

Создание циклической ссылки

Давайте посмотрим, как может возникнуть циклическая ссылка, и как её предотвратить, начиная с определения перечисления List и метода tail в Листинге 15-25.

Filename: src/main.rs
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

fn main() {}
Listing 15-25: Определение списка cons, хранящего RefCell<T>, чтобы мы могли изменять, на что указывает вариант Cons

Мы используем ещё один вариант определения List из Листинга 15-5. Второй элемент в варианте Cons теперь RefCell<Rc<List>>, что означает, что вместо возможности изменять значение i32, как в Листинге 15-24, мы хотим изменять значение List, на которое указывает вариант Cons. Мы также добавляем метод tail, чтобы нам было удобно получать доступ ко второму элементу, если у нас есть вариант Cons.

В Листинге 15-26 мы добавляем функцию main, которая использует определения из Листинга 15-25. Этот код создаёт список в a и список в b, который указывает на список в a. Затем он изменяет список в a, чтобы он указывал на b, создавая циклическую ссылку. В процессе есть операторы println!, чтобы показать, каковы счётчики ссылок в различные моменты.

Filename: src/main.rs
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

fn main() {
    let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));

    println!("a initial rc count = {}", Rc::strong_count(&a));
    println!("a next item = {:?}", a.tail());

    let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));

    println!("a rc count after b creation = {}", Rc::strong_count(&a));
    println!("b initial rc count = {}", Rc::strong_count(&b));
    println!("b next item = {:?}", b.tail());

    if let Some(link) = a.tail() {
        *link.borrow_mut() = Rc::clone(&b);
    }

    println!("b rc count after changing a = {}", Rc::strong_count(&b));
    println!("a rc count after changing a = {}", Rc::strong_count(&a));

    // Uncomment the next line to see that we have a cycle;
    // it will overflow the stack.
    // println!("a next item = {:?}", a.tail());
}
Listing 15-26: Создание циклической ссылки двух значений List, указывающих друг на друга

Мы создаём экземпляр Rc<List>, содержащий значение List, в переменной a с начальным списком 5, Nil. Затем мы создаём экземпляр Rc<List>, содержащий другое значение List, в переменной b, которое содержит значение 10 и указывает на список в a.

Мы изменяем a, чтобы она указывала на b вместо Nil, создавая цикл. Мы делаем это, используя метод tail для получения ссылки на RefCell<Rc<List>> в a, которую помещаем в переменную link. Затем мы используем метод borrow_mut на RefCell<Rc<List>>, чтобы изменить значение внутри с Rc<List>, содержащего значение Nil, на Rc<List> из b.

Когда мы запускаем этот код, оставив последний println! закомментированным на данный момент, мы получим такой вывод:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.53s
     Running `target/debug/cons-list`
a initial rc count = 1
a next item = Some(RefCell { value: Nil })
a rc count after b creation = 2
b initial rc count = 1
b next item = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
b rc count after changing a = 2
a rc count after changing a = 2

Счётчик ссылок экземпляров Rc<List> как в a, так и в b равен 2 после того, как мы изменили список в a на указание на b. В конце main Rust удаляет переменную b, что уменьшает счётчик ссылок экземпляра Rc<List> в b с 2 до

  1. Память, которую Rc<List> имеет в куче, не будет удалена на этом этапе, потому что её счётчик ссылок равен 1, а не 0. Затем Rust удаляет a, что уменьшает счётчик ссылок экземпляра Rc<List> в a также с 2 до 1. Память этого экземпляра тоже не может быть удалена, потому что другой экземпляр Rc<List> всё ещё ссылается на него. Память, выделенная для списка, останется неочищенной навсегда. Чтобы визуализировать этот циклическую ссылку, мы создали диаграмму на Рисунке 15-4.
Циклическая ссылка списков

Рисунок 15-4: Циклическая ссылка списков a и b, указывающих друг на друга

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

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

Создание циклических ссылок не является простой задачей, но оно возможно. Если у вас есть значения RefCell<T>, содержащие значения Rc<T>, или подобные вложенные комбинации типов с внутренней изменяемостью и подсчётом ссылок, вы должны убедиться, что не создаёте циклы; вы не можете полагаться на Rust, чтобы обнаружить их. Создание циклической ссылки было бы логической ошибкой в вашей программе, которую вы должны минимизировать с помощью автоматических тестов, ревью кода и других практик разработки программного обеспечения.

Другое решение для избежания циклических ссылок — реорганизация структур данных так, чтобы некоторые ссылки выражали владение, а некоторые — нет. В результате вы можете иметь циклы, состоящие из некоторых отношений владения и некоторых отношений без владения, и только отношения владения влияют на то, может ли значение быть удалено. В Листинге 15-25 мы всегда хотим, чтобы варианты Cons владели своим списком, поэтому реорганизация структуры данных невозможна. Давайте посмотрим на пример с графами, состоящими из родительских и дочерних узлов, чтобы увидеть, когда отношения без владения являются подходящим способом предотвращения циклических ссылок.

Предотвращение циклических ссылок с использованием Weak<T>

До сих пор мы демонстрировали, что вызов Rc::clone увеличивает strong_count экземпляра Rc<T>, и экземпляр Rc<T> очищается только если его strong_count равен 0. Вы также можете создать слабую ссылку на значение внутри экземпляра Rc<T>, вызвав Rc::downgrade и передав ссылку на Rc<T>. Сильные ссылки — это то, как вы можете разделять владение экземпляром Rc<T>. Слабые ссылки не выражают отношение владения, и их счётчик не влияет на то, когда экземпляр Rc<T> очищается. Они не вызовут циклическую ссылку, потому что любой цикл, включающий некоторые слабые ссылки, будет разорван, как только счётчик сильных ссылок вовлечённых значений станет 0.

Когда вы вызываете Rc::downgrade, вы получаете умный указатель типа Weak<T>. Вместо увеличения strong_count в экземпляре Rc<T> на 1, вызов Rc::downgrade увеличивает weak_count на 1. Тип Rc<T> использует weak_count для отслеживания количества существующих ссылок Weak<T>, аналогично strong_count. Разница в том, что weak_count не обязательно должен быть 0 для очистки экземпляра Rc<T>.

Поскольку значение, на которое ссылается Weak<T>, могло быть удалено, чтобы сделать что-либо со значением, на которое указывает Weak<T>, вы должны убедиться, что значение всё ещё существует. Сделайте это, вызвав метод upgrade на экземпляре Weak<T>, который вернёт Option<Rc<T>>. Вы получите результат Some, если значение Rc<T> ещё не было удалено, и результат None, если значение Rc<T> было удалено. Поскольку upgrade возвращает Option<Rc<T>>, Rust обеспечит обработку случая Some и случая None, и не будет невалидного указателя.

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

Создание структуры данных дерева: Node с дочерними узлами

Сначала мы построим дерево с узлами, которые знают о своих дочерних узлах. Мы создадим структуру с именем Node, которая хранит своё собственное значение i32, а также ссылки на свои дочерние значения Node:

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

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
}

Мы хотим, чтобы Node владел своими детьми, и мы хотим разделить это владение с переменными, чтобы мы могли напрямую обращаться к каждому Node в дереве. Чтобы сделать это, мы определяем элементы Vec<T> как значения типа Rc<Node>. Мы также хотим изменять, какие узлы являются детьми другого узла, поэтому у нас есть RefCell<T> в children вокруг Vec<Rc<Node>>.

Далее мы используем наше определение структуры и создаём один экземпляр Node с именем leaf со значением 3 и без детей, и другой экземпляр с именем branch со значением 5 и leaf в качестве одного из его детей, как показано в Листинге 15-27.

Filename: src/main.rs
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
}
Listing 15-27: Создание узла leaf без детей и узла branch с leaf в качестве одного из детей

Мы клонируем Rc<Node> в leaf и сохраняем его в branch, что означает, что Node в leaf теперь имеет двух владельцев: leaf и branch. Мы можем перейти от branch к leaf через branch.children, но нет способа перейти от leaf к branch. Причина в том, что у leaf нет ссылки на branch и он не знает, что они связаны. Мы хотим, чтобы leaf знал, что branch — его родитель. Мы сделаем это дальше.

Добавление ссылки от ребёнка к его родителю

Чтобы сделать дочерний узел осведомлённым о его родителе, нам нужно добавить поле parent в наше определение структуры Node. Проблема в том, чтобы решить, каким должен быть тип parent. Мы знаем, что он не может содержать Rc<T>, потому что это создало бы циклическую ссылку с leaf.parent, указывающим на branch, и branch.children, указывающим на leaf, что привело бы к тому, что их значения strong_count никогда не станут 0.

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

Поэтому вместо Rc<T> мы сделаем тип parent использовать Weak<T>, а именно RefCell<Weak<Node>>. Теперь наше определение структуры Node выглядит так:

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

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}

Узел сможет ссылаться на свой родительский узел, но не владеет им. В Листинге 15-28 мы обновляем main, чтобы использовать это новое определение, так что узел leaf будет иметь способ ссылаться на своего родителя, branch.

Filename: src/main.rs
use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}
Listing 15-28: Узел leaf со слабой ссылкой на свой родительский узел branch

Создание узла leaf выглядит похоже на Листинг 15-27, за исключением поля parent: leaf начинается без родителя, поэтому мы создаём новый, пустой экземпляр слабой ссылки Weak<Node>.

На этом этапе, когда мы пытаемся получить ссылку на родителя leaf, используя метод upgrade, мы получаем значение None. Мы видим это в выводе первого оператора println!:

leaf parent = None

Когда мы создаём узел branch, он также будет иметь новую слабую ссылку Weak<Node> в поле parent, потому что у branch нет родительского узла. У нас всё ещё есть leaf в качестве одного из детей branch. Как только у нас есть экземпляр Node в branch, мы можем изменить leaf, чтобы дать ему слабую ссылку Weak<Node> на его родителя. Мы используем метод borrow_mut на RefCell<Weak<Node>> в поле parent узла leaf, а затем используем функцию Rc::downgrade для создания слабой ссылки Weak<Node> на branch из Rc<Node> в branch.

Когда мы снова печатаем родителя leaf, на этот раз мы получим вариант Some, содержащий branch: теперь leaf может получить доступ к своему родителю! Когда мы печатаем leaf, мы также избегаем цикла, который в конечном итоге привёл к переполнению стека, как в Листинге 15-26; слабые ссылки Weak<Node> печатаются как (Weak):

leaf parent = Some(Node { value: 5, parent: RefCell { value: (Weak) },
children: RefCell { value: [Node { value: 3, parent: RefCell { value: (Weak) },
children: RefCell { value: [] } }] } })

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

Визуализация изменений в strong_count и weak_count

Давайте посмотрим, как значения strong_count и weak_count экземпляров Rc<Node> изменяются, создав новую внутреннюю область видимости и переместив создание branch в эту область. Делая так, мы можем увидеть, что происходит, когда branch создаётся, а затем удаляется, когда он выходит из области видимости. Изменения показаны в Листинге 15-29.

Filename: src/main.rs
use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );

    {
        let branch = Rc::new(Node {
            value: 5,
            parent: RefCell::new(Weak::new()),
            children: RefCell::new(vec![Rc::clone(&leaf)]),
        });

        *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

        println!(
            "branch strong = {}, weak = {}",
            Rc::strong_count(&branch),
            Rc::weak_count(&branch),
        );

        println!(
            "leaf strong = {}, weak = {}",
            Rc::strong_count(&leaf),
            Rc::weak_count(&leaf),
        );
    }

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );
}
Listing 15-29: Создание branch во внутренней области видимости и проверка счётчиков сильных и слабых ссылок

После создания leaf его Rc<Node> имеет сильный счётчик 1 и слабый счётчик 0. Во внутренней области видимости мы создаём branch и связываем его с leaf, после чего, когда мы печатаем счётчики, Rc<Node> в branch будет иметь сильный счётчик 1 и слабый счётчик 1 (за то, что leaf.parent указывает на branch с помощью Weak<Node>). Когда мы печатаем счётчики в leaf, мы увидим, что у него будет сильный счётчик 2, потому что branch теперь имеет клонированную копию Rc<Node> из leaf, хранящуюся в branch.children, но всё ещё будет иметь слабый счётчик 0.

Когда внутренняя область видимости заканчивается, branch выходит из области видимости, и сильный счётчик Rc<Node> уменьшается до 0, поэтому его Node удаляется. Слабый счётчик 1 от leaf.parent не влияет на то, будет ли Node удалён, поэтому мы не получаем утечек памяти!

Если мы попытаемся получить доступ к родителю leaf после конца области видимости, мы снова получим None. В конце программы Rc<Node> в leaf имеет сильный счётчик 1 и слабый счётчик 0, потому что переменная leaf теперь единственная ссылка на Rc<Node> снова.

Вся логика, управляющая счётчиками и удалением значений, встроена в Rc<T> и Weak<T> и их реализации типажа Drop. Указав, что отношение от ребёнка к его родителю должно быть ссылкой Weak<T> в определении Node, вы можете иметь родительские узлы, указывающие на дочерние узлы, и наоборот, не создавая циклическую ссылку и утечки памяти.

Краткое содержание

В этой главе мы рассмотрели, как использовать умные указатели для обеспечения других гарантий и компромиссов по сравнению с теми, которые Rust делает по умолчанию с обычными ссылками. Тип Box<T> имеет известный размер и указывает на данные, выделенные в куче. Тип Rc<T> отслеживает количество ссылок на данные в куче, чтобы данные могли иметь нескольких владельцев. Тип RefCell<T> со своей внутренней изменяемостью даёт нам тип, который мы можем использовать, когда нам нужен неизменяемый тип, но нужно изменить внутреннее значение этого типа; он также обеспечивает проверку правил заимствования во время выполнения вместо во время компиляции.

Также были обсуждены типажи Deref и Drop, которые обеспечивают большую часть функциональности умных указателей. Мы исследовали циклические ссылки, которые могут вызывать утечки памяти, и как предотвращать их с помощью Weak<T>.

Если эта глава заинтересовала вас, и вы хотите реализовать свои собственные умные указатели, ознакомьтесь с “The Rustonomicon” для получения дополнительной полезной информации.

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

Безопасная конкурентность

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

Изначально команда Rust считала, что обеспечение безопасности памяти и предотвращение проблем с конкурентностью — это две отдельные задачи, требующие разных методов. Со временем команда обнаружила, что система владения и система типов представляют собой мощный набор инструментов для управления как безопасностью памяти, так и проблемами конкурентности! Благодаря использованию владения и проверки типов многие ошибки конкурентности в Rust являются ошибками времени компиляции, а не времени выполнения. Поэтому, вместо того чтобы тратить много времени на попытки воспроизвести точные обстоятельства возникновения ошибки конкурентности во время выполнения, некорректный код просто не скомпилируется и выдаст ошибку с объяснением проблемы. В результате вы можете исправить код в процессе работы над ним, а не потенциально после его отправки в продакшен. Мы назвали этот аспект Rust безопасной конкурентностью (fearless concurrency). Безопасная конкурентность позволяет писать код, свободный от тонких ошибок, и легко рефакторить его, не внося новых ошибок.

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

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

Вот темы, которые мы рассмотрим в этой главе:

  • Как создавать потоки для одновременного выполнения нескольких фрагментов кода
  • Конкурентность на основе передачи сообщений, при которой каналы отправляют сообщения между потоками
  • Конкурентность на основе разделяемого состояния, при которой несколько потоков имеют доступ к некоторому фрагменту данных
  • Типажи Sync и Send, которые распространяют гарантии конкурентности Rust на пользовательские типы, а также на типы, предоставляемые стандартной библиотекой

Использование потоков для одновременного выполнения кода

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

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

  • Гонки данных, при которой потоки обращаются к данным или ресурсам в непоследовательном порядке
  • Взаимные блокировки, при которых два потока ждут друг друга, не давая ни одному из них продолжить выполнение
  • Ошибки, которые происходят только в определенных ситуациях и трудно воспроизвести и исправить надёжно

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

Языки программирования реализуют потоки несколькими разными способами, и многие операционные системы предоставляют API, который язык может вызывать для создания новых потоков. Стандартная библиотека Rust использует модель реализации потоков 1:1, при которой программа использует один поток операционной системы на один поток языка. Существуют крейты, реализующие другие модели потоков, которые идут на компромиссы по сравнению с моделью 1:1. (Асинхронная система Rust, которую мы увидим в следующей главе, также предоставляет другой подход к конкурентности.)

Создание нового потока с помощью spawn

Чтобы создать новый поток, мы вызываем функцию thread::spawn и передаём ей замыкание (мы говорили о замыканиях в главе 13), содержащее код, который мы хотим выполнить в новом потоке. Пример в листинге 16-1 выводит некоторый текст из главного потока и другой текст из нового потока:

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}
Listing 16-1: Создание нового потока для вывода одного сообщения, в то время как главный поток выводит что-то другое

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

hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!

Вызовы thread::sleep заставляют поток прекратить своё выполнение на короткое время, позволяя другому потоку выполниться. Потоки, вероятно, будут по очереди, но это не гарантировано: это зависит от того, как ваша операционная система планирует выполнение потоков. В этом запуске главный поток вывел текст первым, даже though оператор вывода из порождённого потока appears первым в коде. И даже though мы сказали порождённому потоку выводить до тех пор, пока i не станет 9, он добрался только до 5, прежде чем главный поток завершился.

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

Ожидание завершения всех потоков с использованием join Handles

Код в листинге 16-1 не только останавливает порождённый поток преждевременно в большинстве случаев из-за завершения главного потока, но и потому, что нет гарантии порядка выполнения потоков, мы также не можем гарантировать, что порождённый поток получит возможность выполниться вообще!

Мы можем исправить проблему с тем, что порождённый поток не выполняется или завершается преждевременно, сохранив возвращаемое значение thread::spawn в переменной. Возвращаемый тип thread::spawn — это JoinHandle<T>. JoinHandle<T> — это владеющее значение, которое, когда мы вызываем метод join на нём, будет ждать завершения своего потока. Листинг 16-2 показывает, как использовать JoinHandle<T> потока, который мы создали в листинге 16-1, и как вызвать join, чтобы убедиться, что порождённый поток завершится до выхода из main.

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}
Listing 16-2: Сохранение JoinHandle<T> из thread::spawn для гарантии того, что поток выполнится до конца

Вызов join на handle блокирует текущий выполняющийся поток до тех пор, пока поток, представленный handle, не завершится. Блокировка потока означает, что этому потоку предотвращается выполнение работы или выход. Поскольку мы поместили вызов join после цикла for главного потока, запуск листинга 16-2 должен produce вывод, похожий на этот:

hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!

Два потока продолжают чередоваться, но главный поток ждёт из-за вызова handle.join() и не завершается, пока порождённый поток не закончит.

Но давайте посмотрим, что происходит, когда мы вместо этого перемещаем handle.join() перед циклом for в main, вот так:

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    handle.join().unwrap();

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}

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

hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!

Мелкие детали, такие как то, где вызывается join, могут повлиять на то, выполняются ли ваши потоки одновременно.

Использование замыканий move с потоками

Мы часто будем использовать ключевое слово move с замыканиями, передаваемыми в thread::spawn, потому что замыкание тогда примет владение значениями, которые оно использует из окружения, тем самым передавая владение этими значениями из одного потока в другой. В “Захват окружения с помощью замыканий” в главе 13 мы обсуждали move в контексте замыканий. Теперь мы сосредоточимся больше на взаимодействии между move и thread::spawn.

Заметьте в листинге 16-1, что замыкание, которое мы передаём в thread::spawn, не принимает аргументов: мы не используем никакие данные из главного потока в коде порождённого потока. Чтобы использовать данные из главного потока в порождённом потоке, замыкание порождённого потока должно захватить значения, которые ему нужны. Листинг 16-3 показывает попытку создать вектор в главном потоке и использовать его в порождённом потоке. Однако это пока не сработает, как вы увидите в момент.

Filename: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}
Listing 16-3: Попытка использовать вектор, созданный главным потоком, в другом потоке

Замыкание использует v, поэтому оно захватит v и сделает его частью окружения замыкания. Поскольку thread::spawn запускает это замыкание в новом потоке, мы должны иметь возможность получить доступ к v внутри этого нового потока. Но когда мы компилируем этот пример, мы получаем следующую ошибку:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `v`
7 |         println!("Here's a vector: {v:?}");
  |                                     - `v` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:6:18
  |
6 |       let handle = thread::spawn(|| {
  |  __________________^
7 | |         println!("Here's a vector: {v:?}");
8 | |     });
  | |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

For more information about this error, try `rustc --explain E0373`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Rust выводит, как захватить v, и поскольку println! нуждается только в ссылке на v, замыкание пытается заимствовать v. Однако есть проблема: Rust не может сказать, как долго будет выполняться порождённый поток, поэтому он не знает, будет ли ссылка на v всегда действительной.

Листинг 16-4 предоставляет сценарий, который с большей вероятностью приведёт к ссылке на v, которая не будет действительной:

Filename: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    drop(v); // oh no!

    handle.join().unwrap();
}
Listing 16-4: Поток с замыканием, которое пытается захватить ссылку на v из главного потока, который удаляет v

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

Чтобы исправить ошибку компиляции в листинге 16-3, мы можем использовать совет из сообщения об ошибке:

help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

Добавив ключевое слово move перед замыканием, мы заставляем замыкание принять владение используемыми значениями, а не позволяя Rust вывести, что он должен заимствовать значения. Изменение в листинге 16-3, показанное в листинге 16-5, скомпилируется и будет работать, как мы задумали.

Filename: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}
Listing 16-5: Использование ключевого слова move для принудительного захвата замыканием владения используемыми значениями

Мы можем быть склонны попробовать то же самое, чтобы исправить код в листинге 16-4, где главный поток вызвал drop, используя замыкание move. Однако это исправление не сработает, потому что то, что пытается сделать листинг 16-4, запрещено по другой причине. Если мы добавим move к замыканию, мы переместим v в окружение замыкания, и мы больше не сможем вызвать drop на нём в главном потоке. Мы получим эту ошибку компиляции вместо:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0382]: use of moved value: `v`
  --> src/main.rs:10:10
   |
4  |     let v = vec![1, 2, 3];
   |         - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
5  |
6  |     let handle = thread::spawn(move || {
   |                                ------- value moved into closure here
7  |         println!("Here's a vector: {v:?}");
   |                                     - variable moved due to use in closure
...
10 |     drop(v); // oh no!
   |          ^ value used here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Правила владения Rust снова нас спасли! Мы получили ошибку из кода в листинге 16-3, потому что Rust был консервативен и только заимствовал v для потока, что означало, что главный поток теоретически мог сделать ссылку порождённого потока недействительной. Сообщив Rust переместить владение v в порождённый поток, мы гарантируем Rust, что главный поток больше не будет использовать v. Если мы изменим листинг 16-4 таким же образом, мы тогда нарушим правила владения, когда попытаемся использовать v в главном потоке. Ключевое слово move переопределяет консервативный по умолчанию подход Rust к заимствованию; оно не позволяет нам нарушать правила владения.

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

Использование передачи сообщений для обмена данными между потоками

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

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

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

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

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

Сначала, в Листинге 16-6, мы создадим канал, но ничего с ним не будем делать. Обратите внимание, что это пока не скомпилируется, потому что Rust не может определить, какой тип значений мы хотим отправлять по каналу.

Filename: src/main.rs
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
}
Listing 16-6: Создание канала и присвоение двух половин переменным tx и rx

Мы создаём новый канал с помощью функции mpsc::channel; mpsc означает multiple producer, single consumer (множество производителей, один потребитель). Если коротко, способ реализации каналов в стандартной библиотеке Rust означает, что канал может иметь несколько отправляющих концов, которые производят значения, но только один принимающий конец, который потребляет эти значения. Представьте несколько потоков, сливающихся в одну большую реку: всё, что отправлено по любому из потоков, в итоге окажется в одной реке. Мы начнём с одного производителя, но добавим нескольких, когда этот пример заработает.

Функция mpsc::channel возвращает кортеж, первым элементом которого является отправляющий конец — передатчик, а вторым — принимающий конец — приёмник. Сокращения tx и rx традиционно используются во многих областях для transmitter (передатчик) и receiver (приёмник) соответственно, поэтому мы называем наши переменные так, чтобы обозначить каждый конец. Мы используем оператор let с паттерном, который деструктурирует кортеж; об использовании паттернов в операторах let и деструктурировании мы поговорим в Главе 19. Пока просто знайте, что использование оператора let таким образом — это удобный способ извлечь части кортежа, возвращаемого mpsc::channel.

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

Filename: src/main.rs
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });
}
Listing 16-7: Перенос tx в порождённый поток и отправка "hi"

Снова мы используем thread::spawn для создания нового потока, а затем используем move, чтобы переместить tx в замыкание, поэтому порождённый поток владеет tx. Порождённому потоку нужно владеть передатчиком, чтобы иметь возможность отправлять сообщения по каналу.

У передатчика есть метод send, который принимает значение, которое мы хотим отправить. Метод send возвращает тип Result<T, E>, поэтому если приёмник уже был удалён и nowhere нет, куда отправлять значение, операция отправки вернёт ошибку. В этом примере мы вызываем unwrap, чтобы вызвать панику в случае ошибки. Но в реальном приложении мы бы обработали это правильно: вернитесь к Главе 9, чтобы повторить стратегии правильной обработки ошибок.

В Листинге 16-8 мы получим значение из приёмника в основном потоке. Это похоже на извлечение резиновой уточки из воды в конце реки или получение сообщения в чате.

Filename: src/main.rs
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });

    let received = rx.recv().unwrap();
    println!("Got: {received}");
}
Listing 16-8: Получение значения "hi" в основном потоке и вывод его

У приёмника есть два полезных метода: recv и try_recv. Мы используем recv, сокращение от receive (получение), который заблокирует выполнение основного потока и будет ждать, пока значение не будет отправлено по каналу. Как только значение отправлено, recv вернёт его в Result<T, E>. Когда передатчик закрывается, recv вернёт ошибку, чтобы сигнализировать, что больше значения не придут.

Метод try_recv не блокируется, а вместо этого сразу возвращает Result<T, E>: значение Ok, содержащее сообщение, если оно доступно, и значение Err, если сообщений сейчас нет. Использование try_recv полезно, если этому потоку есть другая работа, пока он ждёт сообщений: мы могли бы написать цикл, который периодически вызывает try_recv, обрабатывает сообщение, если оно доступно, а иначе делает другую работу немного, а затем проверяет снова.

Мы использовали recv в этом примере для простоты; у нас нет другой работы для основного потока, кроме как ждать сообщений, поэтому блокировка основного потока уместна.

Когда мы запускаем код в Листинге 16-8, мы увидим значение, выведенное из основного потока:

Got: hi

Отлично!

Каналы и передача владения

Правила владения играют жизненно важную роль в отправке сообщений, потому что они помогают писать безопасный конкурентный код. Предотвращение ошибок в конкурентном программировании — это преимущество осмысления владения во всех ваших программах на Rust. Давайте проведём эксперимент, чтобы показать, как каналы и владение работают вместе, чтобы предотвратить проблемы: мы попытаемся использовать значение val в порождённом потоке после того, как отправили его по каналу. Попробуйте скомпилировать код в Листинге 16-9, чтобы увидеть, почему этот код не разрешён.

Filename: src/main.rs
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
        println!("val is {val}");
    });

    let received = rx.recv().unwrap();
    println!("Got: {received}");
}
Listing 16-9: Попытка использовать val после отправки по каналу

Здесь мы пытаемся вывести val после того, как отправили его по каналу через tx.send. Разрешить это было бы плохой идеей: как только значение отправлено в другой поток, этот поток мог бы изменить или удалить его до того, как мы попытаемся использовать значение снова. Потенциально изменения другого потока могли бы вызвать ошибки или неожиданные результаты из-за несогласованных или несуществующих данных. Однако Rust выдаёт ошибку, если мы пытаемся скомпилировать код в Листинге 16-9:

$ cargo run
   Compiling message-passing v0.1.0 (file:///projects/message-passing)
error[E0382]: borrow of moved value: `val`
  --> src/main.rs:10:26
   |
8  |         let val = String::from("hi");
   |             --- move occurs because `val` has type `String`, which does not implement the `Copy` trait
9  |         tx.send(val).unwrap();
   |                 --- value moved here
10 |         println!("val is {val}");
   |                          ^^^^^ 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)

For more information about this error, try `rustc --explain E0382`.
error: could not compile `message-passing` (bin "message-passing") due to 1 previous error

Наша ошибка конкурентности вызвала ошибку компиляции. Функция send принимает владение своим параметром, и когда значение перемещается, приёмник принимает владение им. Это не позволяет нам случайно использовать значение снова после отправки; система владения проверяет, что всё в порядке.

Отправка нескольких значений и наблюдение за ожиданием приёмника

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

Filename: src/main.rs
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {received}");
    }
}
Listing 16-10: Отправка нескольких сообщений и пауза между каждым

На этот раз порождённый поток имеет вектор строк, которые мы хотим отправить в основной поток. Мы перебираем их, отправляя каждое по отдельности, и делаем паузу между каждым, вызывая функцию thread::sleep со значением Duration в одну секунду.

В основном потоке мы больше не вызываем функцию recv явно: вместо этого мы обращаемся с rx как с итератором. Для каждого полученного значения мы выводим его. Когда канал закрывается, итерация завершится.

При запуске кода в Листинге 16-10 вы должны увидеть следующий вывод с паузой в одну секунду между каждой строкой:

Got: hi
Got: from
Got: the
Got: thread

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

Создание нескольких производителей путём клонирования передатчика

Ранее мы упомянули, что mpsc — это акроним для multiple producer, single consumer (множество производителей, один потребитель). Давайте используем mpsc и расширим код в Листинге 16-10, чтобы создать несколько потоков, которые все отправляют значения одному приёмнику. Мы можем сделать это, склонировав передатчик, как показано в Листинге 16-11.

Filename: src/main.rs
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    // --snip--

    let (tx, rx) = mpsc::channel();

    let tx1 = tx.clone();
    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx1.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    thread::spawn(move || {
        let vals = vec![
            String::from("more"),
            String::from("messages"),
            String::from("for"),
            String::from("you"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {received}");
    }

    // --snip--
}
Listing 16-11: Отправка нескольких сообщений от нескольких производителей

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

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

Got: hi
Got: more
Got: from
Got: messages
Got: for
Got: the
Got: thread
Got: you

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

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

Конкурентность с общим состоянием

Передача сообщений — хороший способ работы с конкурентностью, но это не единственный вариант. Другой метод — это доступ нескольких потоков к одним и тем же общим данным. Ещё раз вспомните лозунг из документации языка Go: «Не общайтесь, разделяя память».

Как выглядит общение через разделение памяти? И почему сторонники передачи сообщений предостерегают от использования разделения памяти?

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

Использование мьютексов для предоставления доступа к данным из одного потока в каждый момент времени

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

Мьютексы имеют репутацию сложных в использовании, потому что нужно помнить два правила:

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

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

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

API Mutex<T>

В качестве примера использования мьютекса начнём с использования мьютекса в однопоточном контексте, как показано в листинге 16-12.

Filename: src/main.rs
use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);

    {
        let mut num = m.lock().unwrap();
        *num = 6;
    }

    println!("m = {m:?}");
}
Listing 16-12: Изучение API Mutex<T> в однопоточном контексте для простоты

Как и со многими типами, мы создаём Mutex<T> с использованием ассоциированной функции new. Для доступа к данным внутри мьютекса мы используем метод lock для получения блокировки. Этот вызов блокирует текущий поток, чтобы он не мог выполнять никакую работу, пока не наступит наша очередь иметь блокировку.

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

После того как мы получили блокировку, мы можем рассматривать возвращаемое значение, названное num в этом случае, как изменяемую ссылку на данные внутри. Система типов гарантирует, что мы получаем блокировку перед использованием значения в m. Тип m — это Mutex<i32>, а не i32, поэтому мы должны вызвать lock, чтобы использовать значение i32. Мы не можем забыть об этом; система типов не позволит нам получить доступ к внутреннему i32 иначе.

Как вы могли предполагать, Mutex<T> — это умный указатель. Точнее, вызов lock возвращает умный указатель под названием MutexGuard, обёрнутый в LockResult, который мы обработали вызовом unwrap. Умный указатель MutexGuard реализует Deref, чтобы указывать на наши внутренние данные; умный указатель также имеет реализацию Drop, которая автоматически освобождает блокировку, когда MutexGuard выходит из области видимости, что происходит в конце внутренней области видимости. В результате мы не рискуем забыть освободить блокировку и заблокировать мьютекс от использования другими потоками, потому что освобождение блокировки происходит автоматически.

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

Общий Mutex<T> между несколькими потоками

Теперь попробуем разделить значение между несколькими потоками с использованием Mutex<T>. Мы запустим 10 потоков и заставим каждый из них увеличить значение счётчика на 1, так что счётчик перейдёт от 0 до 10. Пример в листинге 16-13 не скомпилируется, и мы используем эту ошибку, чтобы узнать больше об использовании Mutex<T> и о том, как Rust помогает нам использовать его правильно.

Filename: src/main.rs
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];

    for _ in 0..10 {
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-13: Десять потоков, каждый из которых увеличивает счётчик, защищённый Mutex<T>

Мы создаём переменную counter для хранения i32 внутри Mutex<T>, как в листинге 16-12. Далее мы создаём 10 потоков, перебирая диапазон чисел. Мы используем thread::spawn и даём всем потокам одно и то же замыкание: то, которое перемещает счётчик в поток, получает блокировку на Mutex<T>, вызвав метод lock, а затем добавляет 1 к значению в мьютексе. Когда поток завершает выполнение своего замыкания, num выйдет из области видимости и освободит блокировку, чтобы другой поток мог её получить.

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

Мы намекнули, что этот пример не скомпилируется. Теперь узнаем почему!

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0382]: borrow of moved value: `counter`
  --> src/main.rs:21:29
   |
5  |     let counter = Mutex::new(0);
   |         ------- move occurs because `counter` has type `Mutex<i32>`, which does not implement the `Copy` trait
...
8  |     for _ in 0..10 {
   |     -------------- inside of this loop
9  |         let handle = thread::spawn(move || {
   |                                    ------- value moved into closure here, in previous iteration of loop
...
21 |     println!("Result: {}", *counter.lock().unwrap());
   |                             ^^^^^^^ value borrowed here after move
   |
help: consider moving the expression out of the loop so it is only moved once
   |
8  ~     let mut value = counter.lock();
9  ~     for _ in 0..10 {
10 |         let handle = thread::spawn(move || {
11 ~             let mut num = value.unwrap();
   |

For more information about this error, try `rustc --explain E0382`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error

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

Множественное владение с несколькими потоками

В главе 15 мы передавали значение нескольким владельцам, используя умный указатель Rc<T> для создания значения с подсчётом ссылок. Давайте сделаем то же самое здесь и посмотрим, что произойдёт. Мы обернём Mutex<T> в Rc<T> в листинге 16-14 и клонируем Rc<T> перед перемещением владения в поток.

Filename: src/main.rs
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Rc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Rc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-14: Попытка использовать Rc<T> для того, чтобы позволить нескольким потокам владеть Mutex<T>

Снова компилируем и получаем… другие ошибки! Компилятор учит нас многому.

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely
  --> src/main.rs:11:36
   |
11 |           let handle = thread::spawn(move || {
   |                        ------------- ^------
   |                        |             |
   |  ______________________|_____________within this `{closure@src/main.rs:11:36: 11:43}`
   | |                      |
   | |                      required by a bound introduced by this call
12 | |             let mut num = counter.lock().unwrap();
13 | |
14 | |             *num += 1;
15 | |         });
   | |_________^ `Rc<Mutex<i32>>` cannot be sent between threads safely
   |
   = help: within `{closure@src/main.rs:11:36: 11:43}`, the trait `Send` is not implemented for `Rc<Mutex<i32>>`
note: required because it's used within this closure
  --> src/main.rs:11:36
   |
11 |         let handle = thread::spawn(move || {
   |                                    ^^^^^^^
note: required by a bound in `spawn`
  --> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/std/src/thread/mod.rs:728:1

For more information about this error, try `rustc --explain E0277`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error

Ух вы, это сообщение об ошибке очень многословное! Вот важная часть, на которую стоит обратить внимание: `Rc<Mutex<i32>>` не может быть безопасно отправлен между потоками. Компилятор также сообщает причину: типаж `Send` не реализован для `Rc<Mutex<i32>>. Мы поговорим о Send в следующем разделе: это один из типажей, который гарантирует, что типы, которые мы используем с потоками, предназначены для использования в конкурентных ситуациях.

К сожалению, Rc<T> небезопасен для разделения между потоками. Когда Rc<T> управляет подсчётом ссылок, он увеличивает счёт для каждого вызова clone и уменьшает счёт, когда каждый клон уничтожается. Но он не использует примитивы конкурентности, чтобы убедиться, что изменения счёта не могут быть прерваны другим потоком. Это может привести к неверным подсчётам — тонким ошибкам, которые, в свою очередь, могут привести к утечкам памяти или уничтожению значения до того, как мы с ним закончим. Нам нужен тип, который точно такой же, как Rc<T>, но который изменяет подсчёт ссылок потокобезопасным способом.

Атомарный подсчёт ссылок с Arc<T>

К счастью, Arc<T> — это тип, подобный Rc<T>, который безопасно использовать в конкурентных ситуациях. Буква a означает атомарный, то есть это атомарно подсчитываемый по ссылкам тип. Атомарные операции — это дополнительный вид примитива конкурентности, который мы здесь подробно не рассматриваем: см. документацию стандартной библиотеки по std::sync::atomic для более подробной информации. На данный момент вам просто нужно знать, что атомарные операции работают как примитивные типы, но безопасны для разделения между потоками.

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

Вернёмся к нашему примеру: Arc<T> и Rc<T> имеют одинаковый API, поэтому мы исправляем нашу программу, изменив строку use, вызов new и вызов clone. Код в листинге 16-15 наконец скомпилируется и запустится.

Filename: src/main.rs
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-15: Использование Arc<T> для обёртки Mutex<T> с целью возможности разделения владения между несколькими потоками

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

Result: 10

У нас получилось! Мы посчитали от 0 до 10, что может показаться не очень впечатляющим, но это многое нам рассказало о Mutex<T> и безопасности потоков. Вы также могли бы использовать структуру этой программы для более сложных операций, чем просто увеличение счётчика. Используя эту стратегию, вы можете разделить вычисление на независимые части, распределить эти части по потокам, а затем использовать Mutex<T>, чтобы каждый поток обновлял окончательный результат своей частью.

Обратите внимание, что если вы выполняете простые числовые операции, существуют типы проще, чем Mutex<T>, предоставляемые модулем std::sync::atomic стандартной библиотеки. Эти типы обеспечивают безопасный, конкурентный, атомарный доступ к примитивным типам. Мы выбрали использование Mutex<T> с примитивным типом для этого примера, чтобы сосредоточиться на том, как работает Mutex<T>.

Сходство между RefCell<T>/Rc<T> и Mutex<T>/Arc<T>

Вы могли заметить, что counter является неизменяемым, но мы могли получить изменяемую ссылку на значение внутри него; это означает, что Mutex<T> обеспечивает внутреннюю изменяемость, как и семейство Cell. Так же, как мы использовали RefCell<T> в главе 15, чтобы позволить ourselves изменять содержимое внутри Rc<T>, мы используем Mutex<T> для изменения содержимого внутри Arc<T>.

Ещё один момент, на который стоит обратить внимание: Rust не может защитить вас от всех видов логических ошибок при использовании Mutex<T>. Вспомните из главы 15, что использование Rc<T> несло риск создания циклических ссылок, где два значения Rc<T> ссылаются друг на друга, вызывая утечки памяти. Аналогично, Mutex<T> несёт риск создания взаимной блокировки (deadlock). Они возникают, когда операция должна заблокировать два ресурса, и два потока уже получили по одной из блокировок, заставляя их ждать друг друга вечно. Если вас интересуют взаимные блокировки, попробуйте создать программу на Rust, которая имеет взаимную блокировку; затем изучите стратегии смягчения взаимных блокировок для мьютексов в любом языке и попробуйте реализовать их на Rust. Документация API стандартной библиотеки для Mutex<T> и MutexGuard предлагает полезную информацию.

Мы завершим эту главу, поговорив о типажах Send и Sync и о том, как мы можем использовать их с пользовательскими типами.

Расширяемая конкурентность с помощью типажей Send и Sync

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

Однако среди ключевых концепций конкурентности, встроенных в язык, а не в стандартную библиотеку, находятся маркер-типажи std::marker Send и Sync.

Разрешение передачи владения между потоками с помощью Send

Маркер-типаж Send указывает, что владение значениями типа, реализующего Send, может быть передано между потоками. Почти каждый тип Rust является Send, но есть некоторые исключения, включая Rc<T>: он не может реализовать Send, потому что если бы вы клонировали значение Rc<T> и попытались передать владение клоном другому потоку, оба потока могли бы одновременно обновлять счётчик ссылок. По этой причине Rc<T> реализован для использования в однопоточных ситуациях, где вы не хотите платить штраф производительности за потокобезопасность.

Таким образом, система типов Rust и ограничения типажей гарантируют, что вы никогда не отправите значение Rc<T> между потоками небезопасным образом. Когда мы пытались сделать это в Листинге 16-14, мы получили ошибку the trait Send is not implemented for Rc<Mutex<i32>>. Когда мы переключились на Arc<T>, который реализует Send, код скомпилировался.

Любой тип, состоящий полностью из типов Send, автоматически также помечается как Send. Почти все примитивные типы являются Send, за исключением сырых указателей, которые мы обсудим в Главе 20.

Разрешение доступа из нескольких потоков с помощью Sync

Маркер-типаж Sync указывает, что для типа, реализующего Sync, безопасно иметь ссылку из нескольких потоков. Другими словами, любой тип T реализует Sync, если &T (неизменяемая ссылка на T) реализует Send, что означает, что ссылку можно безопасно отправить в другой поток. Подобно Send, все примитивные типы реализуют Sync, и типы, состоящие полностью из типов, которые реализуют Sync, также реализуют Sync.

Sync — это наиболее близкое понятие в Rust к разговорному значению фразы “потокобезопасный” (thread-safe), т.е. что определённый фрагмент данных может безопасно использоваться несколькими параллельными потоками. Причина наличия отдельных типажей Send и Sync в том, что тип иногда может быть одним, или обоими, или ни одним. Например:

  • Умный указатель Rc<T> также не является ни Send, ни Sync по описанным выше причинам.
  • Тип RefCell<T> (о котором мы говорили в Главе 15) и семейство связанных типов Cell<T> являются Send (если T: Send), но они не являются Sync. RefCell можно отправить через границу потока, но не можно получить доступ параллельно, потому что реализация проверки заимствований, которую RefCell<T> выполняет во время выполнения, не является потокобезопасной.
  • Умный указатель Mutex<T> является Send и Sync и может использоваться для общего доступа с несколькими потоками, как вы видели в разделе “Общий доступ к Mutex<T> между несколькими потоками”.
  • Тип MutexGuard<'a, T>, который возвращается Mutex::lock, является Sync (если T: Sync), но не Send. Он именно не Send, потому что некоторые платформы требуют, чтобы мьютексы разблокировались тем же потоком, который их заблокировал.

Ручная реализация Send и Sync является небезопасной

Поскольку типы, состоящие полностью из других типов, которые реализуют типажи Send и Sync, также автоматически реализуют Send и Sync, нам не нужно реализовывать эти типажи вручную. Как маркер-типажи, у них даже нет никаких методов для реализации. Они просто полезны для обеспечения инвариантов, связанных с конкурентностью.

Ручная реализация этих типажей предполагает написание небезопасного кода Rust. Мы поговорим об использовании небезопасного кода Rust в Главе 20; пока что важная информация заключается в том, что создание новых конкурентных типов, не состоящих из частей Send и Sync, требует тщательного обдумывания для поддержания гарантий безопасности. В “Rustonomicon” есть более подробная информация об этих гарантиях и о том, как их поддерживать.

Резюме

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

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

Стандартная библиотека Rust предоставляет каналы для передачи сообщений и типы умных указателей, такие как Mutex<T> и Arc<T>, которые безопасно использовать в конкурентных контекстах. Система типов и проверка заимствований гарантируют, что код, использующий эти решения, не закончится гонками данных или недействительными ссылками. Как только ваш код скомпилируется, вы можете быть уверены, что он будет успешно работать в нескольких потоках без тех трудноуловимых ошибок, которые характерны для других языков. Конкурентное программирование больше не концепция, которой стоит бояться: идите и делайте свои программы конкурентными, без страха!

Основы асинхронного программирования: async, await, фьючерсы и потоки

Многие операции, которые мы поручаем компьютеру, могут выполняться долго. Было бы здорово, если бы мы могли заниматься другим, пока ждём завершения этих длительных процессов. Современные компьютеры предлагают два способа работы над несколькими операциями одновременно: параллелизм и конкурентность. Однако как только мы начинаем писать программы, вовлекающие параллельные или конкурентные операции, мы быстро сталкиваемся с новыми вызовами, присущими асинхронному программированию, где операции могут завершаться не в том порядке, в котором были запущены. Эта глава основывается на использовании потоков для параллелизма и конкурентности из Главы 16, представляя альтернативный подход к асинхронному программированию: фьючерсы и потоки Rust, синтаксис async и await, который их поддерживает, а также инструменты для управления и координации между асинхронными операциями.

Рассмотрим пример. Допустим, вы экспортируете созданное вами видео семейного праздника — операция, которая может занять от минут до часов. Экспорт видео будет использовать как можно больше мощности CPU и GPU. Если бы у вас был только один ядерный процессор и ваша операционная система не приостанавливала бы этот экспорт до его завершения — то есть, если бы он выполнялся синхронно — вы не смогли бы делать ничего другого на компьютере, пока эта задача выполняется. Это было бы очень неприятно. К счастью, операционная система вашего компьютера может и действительно невидимо прерывает экспорт достаточно часто, чтобы позволить вам выполнять другую работу одновременно.

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

Экспорт видео — это пример процессорно-зависимой (или вычислительно-зависимой) операции. Он ограничен потенциальной скоростью обработки данных внутри CPU или GPU и тем, какую часть этой скорости можно посвятить операции. Скачивание видео — это пример операции, зависимой от ввода-вывода, потому что оно ограничено скоростью ввода-вывода компьютера; оно может идти только так быстро, как данные могут передаваться по сети.

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

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

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

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

let data = fetch_data_from(url).await;
println!("{data}");

Это именно то, что даёт нам абстракция async (сокращение от asynchronous) в Rust. В этой главе вы узнаете всё об async, когда мы рассмотрим следующие темы:

  • Как использовать синтаксис async и await в Rust
  • Как использовать асинхронную модель для решения некоторых из тех же задач, которые мы рассматривали в Главе 16
  • Как многопоточность и async предоставляют взаимодополняющие решения, которые можно комбинировать во многих случаях

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

Параллелизм и конкурентность

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

Рассмотрим разные способы, которыми команда может распределить работу над программным проектом. Если каждый член команды берёт одну задачу и работает над ней в одиночку, это параллелизм. Каждый человек в команде может прогрессировать абсолютно одновременно (см. Рисунок 17-2).

В обоих этих рабочих процессах вам, возможно, придётся координировать разные задачи. Может оказаться, что задача, назначенная одному человеку, на самом деле зависит от завершения задачи другим членом команды. Часть работы может выполняться параллельно, но часть была фактически серийной: она могла происходить только последовательно, одна задача за другой, как на Рисунке 17-3.

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

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

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

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

Теперь давайте погрузимся в то, как на практике работает асинхронное программирование в Rust.

Фьючерсы и синтаксис async

Ключевыми элементами асинхронного программирования в Rust являются фьючерсы (futures) и ключевые слова Rust async и await.

Фьючерс — это значение, которое может быть не готово сейчас, но станет готовым в будущем. (Эта же концепция встречается во многих языках, иногда под другими названиями, такими как задача (task) или обещание (promise).) Rust предоставляет типаж (trait) Future в качестве строительного блока, чтобы разные асинхронные операции могли быть реализованы с разными структурами данных, но с общим интерфейсом. В Rust фьючерсы — это типы, которые реализуют типаж Future. Каждый фьючерс хранит собственную информацию о ходе выполнения и о том, что значит “готово”.

Вы можете применить ключевое слово async к блокам и функциям, чтобы указать, что они могут быть прерваны и возобновлены. Внутри async-блока или async-функции вы можете использовать ключевое слово await, чтобы ожидать фьючерс (то есть дождаться, пока он не станет готовым). Любая точка, где вы ожидаете фьючерс внутри async-блока или функции, — это потенциальное место, где этот async-блок или функция могут приостановиться и возобновиться. Процесс проверки, доступно ли уже значение фьючерса, называется опросом (polling).

Некоторые другие языки, такие как C# и JavaScript, также используют ключевые слова async и await для асинхронного программирования. Если вы знакомы с этими языками, вы можете заметить некоторые существенные различия в том, как Rust работает, включая обработку синтаксиса. И на это есть веская причина, как мы увидим!

При написании асинхронного Rust мы используем ключевые слова async и await большую часть времени. Rust компилирует их в эквивалентный код с использованием типажа Future, подобно тому как он компилирует циклы for в эквивалентный код с использованием типажа Iterator. Поскольку Rust предоставляет типаж Future, вы также можете реализовать его для своих собственных типов данных, когда это необходимо. Многие функции, которые мы увидим в этой главе, возвращают типы с собственной реализацией Future. Мы вернёмся к определению этого типажа в конце главы и подробнее разберём, как он работает, но этих деталей достаточно, чтобы двигаться дальше.

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

Наша первая асинхронная программа

Чтобы сосредоточить внимание этой главы на изучении async, а не на управлении частями экосистемы, мы создали крейт trpl (trpl — сокращение от “The Rust Programming Language”). Он повторно экспортирует все типы, типажи и функции, которые вам понадобятся, в основном из крейтов futures и tokio. Крейт futures — это официальное место для экспериментов Rust с асинхронным кодом, и именно там изначально был разработан типаж Future. Tokio — это наиболее широко используемый асинхронный рантайм в Rust на сегодняшний день, особенно для веб-приложений. Существуют и другие отличные рантаймы, и они могут быть более подходящими для ваших целей. Мы используем крейт tokio внутри trpl, потому что он хорошо протестирован и широко распространён.

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

Создайте новый бинарный проект с именем hello-async и добавьте крейт trpl в качестве зависимости:

$ cargo new hello-async
$ cd hello-async
$ cargo add trpl

Теперь мы можем использовать различные компоненты, предоставляемые trpl, чтобы написать нашу первую асинхронную программу. Мы создадим небольшой инструмент командной строки, который загружает две веб-страницы, извлекает из каждой элемент <title> и выводит заголовок той страницы, которая завершит весь этот процесс первой.

Определение функции page_title

Давайте начнём с написания функции, которая принимает один URL страницы в качестве параметра, отправляет на него запрос и возвращает текст элемента заголовка (см. Листинг 17-1).

Filename: src/main.rs
extern crate trpl; // required for mdbook test

fn main() {
    // TODO: we'll add this next!
}

use trpl::Html;

async fn page_title(url: &str) -> Option<String> {
    let response = trpl::get(url).await;
    let response_text = response.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title_element| title_element.inner_html())
}
Listing 17-1: Определение async-функции для получения элемента заголовка из HTML-страницы

Сначала мы определяем функцию с именем page_title и помечаем её ключевым словом async. Затем мы используем функцию trpl::get для загрузки переданного URL и добавляем ключевое слово await, чтобы дождаться ответа. Чтобы получить текст ответа, мы вызываем его метод text и снова ожидаем его с помощью ключевого слова await. Оба этих шага являются асинхронными. Для функции get нам нужно дождаться, пока сервер не отправит первую часть своего ответа, которая будет включать HTTP-заголовки, куки и т.д., и которая может быть доставлена отдельно от тела ответа. Особенно если тело очень большое, на его полную доставку может уйти некоторое время. Поскольку нам нужно дождаться полного прихода ответа, метод text также является асинхронным.

Мы должны явно ожидать оба этих фьючерса, потому что фьючерсы в Rust ленивые: они ничего не делают, пока вы не попросите их об этом с помощью ключевого слова await. (Фактически, Rust покажет предупреждение компилятора, если вы не используете фьючерс.) Это может напомнить вам обсуждение итераторов в Главе 13 в разделе Обработка серии элементов с помощью итераторов. Итераторы ничего не делают, пока вы не вызовете их метод next — будь то напрямую или с помощью циклов for или методов, таких как map, которые используют next под капотом. Точно так же фьючерсы ничего не делают, пока вы явно не попросите их об этом. Эта лень позволяет Rust избежать запуска асинхронного кода, пока он на самом деле не нужен.

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

Как только у нас есть response_text, мы можем разобрать её в экземпляр типа Html с помощью Html::parse. Вместо сырой строки у нас теперь есть тип данных, который мы можем использовать для работы с HTML как с более богатой структурой данных. В частности, мы можем использовать метод select_first, чтобы найти первый экземпляр заданного CSS-селектора. Передав строку "title", мы получим первый элемент <title> в документе, если он есть. Поскольку совпадающего элемента может не быть, select_first возвращает Option<ElementRef>. Наконец, мы используем метод Option::map, который позволяет нам работать с элементом в Option, если он присутствует, и ничего не делать, если его нет. (Мы также могли бы использовать выражение match здесь, но map более идиоматично.) В теле функции, которую мы передаём в map, мы вызываем inner_html для title_element, чтобы получить его содержимое, которое является String. В итоге у нас есть Option<String>.

Обратите внимание, что ключевое слово await в Rust идёт после выражения, которое вы ожидаете, а не перед ним. То есть это постфиксное ключевое слово. Это может отличаться от того, к чему вы привыкли, если использовали async в других языках, но в Rust это делает цепочки методов гораздо более удобными для работы. В результате мы можем изменить тело page_title так, чтобы объединить вызовы функций trpl::get и text вместе с await между ними, как показано в Листинге 17-2.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::Html;

fn main() {
    // TODO: we'll add this next!
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title_element| title_element.inner_html())
}
Listing 17-2: Объединение с ключевым словом await

С этим мы успешно написали нашу первую async-функцию! Прежде чем добавить код в main для её вызова, давайте немного подробнее поговорим о том, что мы написали, и что это значит.

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

Таким образом, написание async fn эквивалентно написанию функции, которая возвращает фьючерс возвращаемого типа. Для компилятора определение функции, такое как async fn page_title в Листинге 17-1, эквивалентно неасинхронной функции, определённой так:

#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test
use std::future::Future;
use trpl::Html;

fn page_title(url: &str) -> impl Future<Output = Option<String>> {
    async move {
        let text = trpl::get(url).await.text().await;
        Html::parse(&text)
            .select_first("title")
            .map(|title| title.inner_html())
    }
}
}

Давайте пройдёмся по каждой части преобразованной версии:

  • Она использует синтаксис impl Trait, который мы обсуждали в Главе 10 в разделе “Типажи как параметры”.
  • Возвращаемый типаж — это Future с ассоциированным типом Output. Обратите внимание, что тип Output — это Option<String>, что совпадает с исходным возвращаемым типом из версии async fn функции page_title.
  • Весь код, вызываемый в теле исходной функции, обёрнут в блок async move. Помните, что блоки — это выражения. Этот весь блок является выражением, возвращаемым из функции.
  • Этот async-блок производит значение с типом Option<String>, как только что было описано. Это значение соответствует типу Output в возвращаемом типе. Это так же, как и в случае с другими блоками, которые вы видели.
  • Новое тело функции — это блок async move из-за того, как оно использует параметр url. (Мы подробнее поговорим о async против async move позже в главе.)

Теперь мы можем вызвать page_title в main.

Определение заголовка одной страницы

Для начала мы просто получим заголовок для одной страницы. В Листинге 17-3 мы следуем тому же шаблону, который использовали в Главе 12 для получения аргументов командной строки в разделе Принятие аргументов командной строки. Затем мы передаём первый URL в page_title и ожидаем результат. Поскольку значение, производимое фьючерсом, является Option<String>, мы используем выражение match, чтобы выводить разные сообщения с учётом того, была ли на странице <title>.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::Html;

async fn main() {
    let args: Vec<String> = std::env::args().collect();
    let url = &args[1];
    match page_title(url).await {
        Some(title) => println!("The title for {url} was {title}"),
        None => println!("{url} had no title"),
    }
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title_element| title_element.inner_html())
}
Listing 17-3: Вызов функции page_title из main с аргументом, предоставленным пользователем

К сожалению, этот код не компилируется. Единственное место, где можно использовать ключевое слово await, — это в async-функциях или блоках, и Rust не позволит нам пометить специальную функцию main как async.

error[E0752]: `main` function is not allowed to be `async`
 --> src/main.rs:6:1
  |
6 | async fn main() {
  | ^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`

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

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

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

Мы могли бы передать фьючерс, возвращаемый page_title, непосредственно в run, и как только он завершится, мы могли бы сопоставить результирующий Option<String>, как мы пытались сделать в Листинге 17-3. Однако для большинства примеров в этой главе (и для большинства асинхронного кода в реальном мире) мы будем делать больше, чем просто один вызов async-функции, поэтому вместо этого мы передадим async-блок и явно ожидаем результат вызова page_title, как в Листинге 17-4.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::Html;

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::run(async {
        let url = &args[1];
        match page_title(url).await {
            Some(title) => println!("The title for {url} was {title}"),
            None => println!("{url} had no title"),
        }
    })
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title_element| title_element.inner_html())
}
Listing 17-4: Ожидание async-блока с помощью trpl::run

Когда мы запускаем этот код, мы получаем ожидаемое поведение:

$ cargo run -- https://www.rust-lang.org
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.05s
     Running `target/debug/async_await 'https://www.rust-lang.org'`
The title for https://www.rust-lang.org was
            Rust Programming Language

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

Каждая точка ожидания (await point) — то есть каждое место, где код использует ключевое слово await — представляет собой место, где управление возвращается рантайму. Чтобы это работало, Rust должен отслеживать состояние, связанное с async-блоком, чтобы рантайм мог запустить другую работу, а затем вернуться, когда будет готов попробовать продвинуть первый снова. Это невидимая машина состояний, как если бы вы написали перечисление (enum) вроде этого для сохранения текущего состояния в каждой точке ожидания:

#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test

enum PageTitleFuture<'a> {
    Initial { url: &'a str },
    GetAwaitPoint { url: &'a str },
    TextAwaitPoint { response: trpl::Response },
}
}

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

В конечном счёте, что-то должно выполнять эту машину состояний, и этим чем-то является рантайм. (Поэтому при изучении рантаймов вы можете встречать ссылки на исполнителей (executors): исполнитель — это часть рантайма, ответственная за выполнение асинхронного кода.)

Теперь вы понимаете, почему компилятор остановил нас от того, чтобы сделать main самой асинхронной функцией в Листинге 17-3. Если бы main была асинхронной функцией, кому-то ещё нужно было бы управлять машиной состояний для любого фьючерса, который возвращает main, но main — это отправная точка программы! Вместо этого мы вызвали функцию trpl::run в main, чтобы настроить рантайм и выполнить фьючерс, возвращаемый async-блоком, до его завершения.

Примечание: Некоторые рантаймы предоставляют макросы, так что вы можете написать асинхронную функцию main. Эти макросы переписывают async fn main() { ... } в обычную fn main, которая делает то же самое, что мы сделали вручную в Листинге 17-4: вызывает функцию, которая выполняет фьючерс до завершения, как это делает trpl::run.

Теперь давайте объединим эти части и посмотрим, как мы можем писать конкурентный код.

Гонка наших двух URL друг против друга

В Листинге 17-5 мы вызываем page_title с двумя разными URL, переданными из командной строки, и устраиваем между ними гонку.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::{Either, Html};

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::run(async {
        let title_fut_1 = page_title(&args[1]);
        let title_fut_2 = page_title(&args[2]);

        let (url, maybe_title) =
            match trpl::race(title_fut_1, title_fut_2).await {
                Either::Left(left) => left,
                Either::Right(right) => right,
            };

        println!("{url} returned first");
        match maybe_title {
            Some(title) => println!("Its page title is: '{title}'"),
            None => println!("Its title could not be parsed."),
        }
    })
}

async fn page_title(url: &str) -> (&str, Option<String>) {
    let text = trpl::get(url).await.text().await;
    let title = Html::parse(&text)
        .select_first("title")
        .map(|title| title.inner_html());
    (url, title)
}
Listing 17-5:

Мы начинаем с вызова page_title для каждого из предоставленных пользователем URL. Сохраняемые фьючерсы мы называем title_fut_1 и title_fut_2. Помните, они ничего не делают пока, потому что фьючерсы ленивые, и мы ещё не ожидали их. Затем мы передаём фьючерсы в trpl::race, которая возвращает значение, указывающее, какой из переданных фьючерсов завершится первым.

Примечание: Под капотом race построена на более общей функции select, с которой вы чаще столкнётесь в реальном коде на Rust. Функция select может делать многое из того, что функция trpl::race не может, но у неё также есть дополнительная сложность, которую мы пока можем пропустить.

Любой фьючерс может законно “выиграть”, поэтому не имеет смысла возвращать Result. Вместо этого race возвращает тип, который мы раньше не видели, trpl::Either. Тип Either несколько похож на Result в том смысле, что у него два случая. В отличие от Result, однако, в Either нет встроенного понятия успеха или неудачи. Вместо этого он использует Left и Right, чтобы указать “один или другой”:

#![allow(unused)]
fn main() {
enum Either<A, B> {
    Left(A),
    Right(B),
}
}

Функция race возвращает Left с выводом первого аргумента-фьючерса, который завершится первым, или Right с выводом второго аргумента-фьючерса, если тот завершится первым. Это соответствует порядку, в котором аргументы появляются при вызове функции: первый аргумент находится слева от второго аргумента.

Мы также обновляем page_title, чтобы она возвращала тот же URL, который был передан. Таким образом, если страница, которая возвращается первой, не имеет разрешаемого <title>, мы всё равно сможем вывести осмысленное сообщение. Имея эту информацию, мы завершаем обновлением нашего вывода println!, чтобы указать и то, какой URL завершился первым, и что, если есть, <title> для веб-страницы по этому URL.

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

Применение конкурентности с async

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

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

Создание новой задачи с spawn_task

Первая операция, которую мы рассмотрели в Создание нового потока с Spawn, — это подсчёт в двух отдельных потоках. Давайте сделаем то же самое, используя async. Крейт trpl предоставляет функцию spawn_task, которая очень похожа на API thread::spawn, и функцию sleep, которая является асинхронной версией API thread::sleep. Мы можем использовать их вместе для реализации примера с подсчётом, как показано в Листинге 17-6.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        trpl::spawn_task(async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        });

        for i in 1..5 {
            println!("hi number {i} from the second task!");
            trpl::sleep(Duration::from_millis(500)).await;
        }
    });
}
Listing 17-6: Создание новой задачи для вывода одного сообщения, пока основная задача выводит что-то ещё

В качестве отправной точки мы настраиваем функцию main с помощью trpl::run, чтобы наша верхнеуровневая функция могла быть async.

Примечание: Начиная с этого момента в главе каждый пример будет включать этот точно такой же обёрточный код с trpl::run в main, поэтому мы часто будем его пропускать, как и делаем с main. Не забывайте включать его в свой код!

Затем мы пишем два цикла внутри этого блока, каждый из которых содержит вызов trpl::sleep, который ждёт полсекунды (500 миллисекунд) перед отправкой следующего сообщения. Мы помещаем один цикл в тело trpl::spawn_task, а другой в верхнеуровневый цикл for. Мы также добавляем await после вызовов sleep.

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

hi number 1 from the second task!
hi number 1 from the first task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!

Эта версия останавливается, как только цикл for в теле основного async-блока завершается, потому что задача, созданная spawn_task, завершается, когда функция main заканчивается. Если вы хотите, чтобы она работала до завершения задачи, вам нужно использовать handle соединения (join handle), чтобы дождаться завершения первой задачи. С потоками мы использовали метод join, чтобы “заблокироваться” до завершения потока. В Листинге 17-7 мы можем использовать await для того же самого, потому что handle задачи сам является future. Его тип Output — это Result, поэтому мы также разворачиваем (unwrap) его после ожидания (await).

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let handle = trpl::spawn_task(async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        });

        for i in 1..5 {
            println!("hi number {i} from the second task!");
            trpl::sleep(Duration::from_millis(500)).await;
        }

        handle.await.unwrap();
    });
}
Listing 17-7: Использование await с join handle для выполнения задачи до завершения

Эта обновлённая версия работает до завершения обоих циклов.

hi number 1 from the second task!
hi number 1 from the first task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!
hi number 6 from the first task!
hi number 7 from the first task!
hi number 8 from the first task!
hi number 9 from the first task!

Пока что кажется, что async и потоки дают нам те же основные результаты, просто с разным синтаксисом: использование await вместо вызова join на join handle и ожидание (await) вызовов sleep.

Более существенное различие в том, что нам не нужно было создавать ещё один операционный системный поток для этого. Фактически, нам даже не нужно создавать задачу здесь. Поскольку async-блоки компилируются в анонимные futures, мы можем поместить каждый цикл в async-блок и позволить рантайму выполнить оба до завершения, используя функцию trpl::join.

В разделе Ожидание завершения всех потоков с помощью join handles мы показали, как использовать метод join на типе JoinHandle, возвращаемом при вызове std::thread::spawn. Функция trpl::join похожа, но для futures. Когда вы передаёте ей два future, она производит один новый future, вывод (output) которого — это кортеж, содержащий вывод каждого переданного future после того, как они оба завершатся. Таким образом, в Листинге 17-8 мы используем trpl::join, чтобы дождаться завершения как fut1, так и fut2. Мы не ожидаем (await) fut1 и fut2, а ожидаем новый future, произведённый trpl::join. Мы игнорируем вывод, потому что это просто кортеж, содержащий два значения типа unit.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let fut1 = async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let fut2 = async {
            for i in 1..5 {
                println!("hi number {i} from the second task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        trpl::join(fut1, fut2).await;
    });
}
Listing 17-8: Использование trpl::join для ожидания двух анонимных futures

При запуске мы видим, что оба future выполняются до завершения:

hi number 1 from the first task!
hi number 1 from the second task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!
hi number 6 from the first task!
hi number 7 from the first task!
hi number 8 from the first task!
hi number 9 from the first task!

Теперь вы увидите точно такой же порядок каждый раз, что очень отличается от того, что мы видели с потоками. Это потому, что функция trpl::join является справедливой (fair), что означает, что она проверяет каждый future одинаково часто, чередуя их, и никогда не позволяет одному обогнать другой, если другой готов. С потоками операционная система решает, какой поток проверять и как долго ему позволять работать. С async Rust рантайм решает, какую задачу проверять. (На практике детали усложняются, потому что асинхронный рантайм может использовать операционные системные потоки под капотом как часть управления конкурентностью, поэтому гарантировать справедливость может быть более сложной задачей для рантайма — но это всё же возможно!) Рантаймы не обязаны гарантировать справедливость для любой данной операции, и они часто предлагают разные API, позволяющие выбрать, хотите ли вы справедливость или нет.

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

  • Удалите async-блок вокруг одного или обоих циклов.
  • Ожидайте (await) каждый async-блок сразу после его определения.
  • Оберните только первый цикл в async-блок и ожидайте (await) полученный future после тела второго цикла.

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

Подсчёт в двух задачах с использованием передачи сообщений

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

Filename: src/main.rs
extern crate trpl; // required for mdbook test

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let val = String::from("hi");
        tx.send(val).unwrap();

        let received = rx.recv().await.unwrap();
        println!("Got: {received}");
    });
}
Listing 17-9: Создание асинхронного канала и присвоение двух половин tx и rx

Здесь мы используем trpl::channel, асинхронную версию API канала многопроизводитель-однопотребитель, который мы использовали с потоками в главе 16. Асинхронная версия API отличается от версии на основе потоков лишь немного: она использует изменяемого (mutable), а не неизменяемого (immutable) приёмника rx, и её метод recv производит future, который нам нужно ожидать (await), вместо того чтобы производить значение напрямую. Теперь мы можем отправлять сообщения от отправителя к получателю. Обратите внимание, что нам не нужно создавать отдельный поток или даже задачу; нам merely нужно ожидать (await) вызов rx.recv.

Синхронный метод Receiver::recv в std::mpsc::channel блокируется до получения сообщения. Метод trpl::Receiver::recv не блокируется, потому что он async. Вместо блокировки он возвращает управление рантайму до тех пор, либо пока не будет получено сообщение, либо пока отправительская сторона канала не закроется. В отличие от этого, мы не ожидаем (await) вызов send, потому что он не блокируется. Ему не нужно блокироваться, потому что канал, в который мы его отправляем, неограниченный (unbounded).

Примечание: Поскольку весь этот async-код выполняется в async-блоке в вызове trpl::run, всё внутри него может избегать блокировок. Однако код вне его будет блокироваться на возврате функции run. В этом и есть вся суть функции trpl::run: она позволяет вам выбрать, где блокироваться на некотором наборе async-кода, и, следовательно, где выполнять переход между sync и async кодом. В большинстве async-рантаймов run на самом деле называется block_on именно по этой причине.

Обратите внимание на две вещи в этом примере. Во-первых, сообщение приходит сразу. Во-вторых, хотя мы используем future здесь, конкуренции (concurrency) ещё нет. Всё в листинге происходит последовательно, как если бы futures не было.

Давайте рассмотрим первую часть, отправив серию сообщений и поставив задержку (sleep) между ними, как показано в Листинге 17-10.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("future"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            trpl::sleep(Duration::from_millis(500)).await;
        }

        while let Some(value) = rx.recv().await {
            println!("received '{value}'");
        }
    });
}
Listing 17-10: Отправка и получение нескольких сообщений через асинхронный канал и засыпание с await между каждым сообщением

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

В Листинге 16-10 мы использовали цикл for для обработки всех элементов, полученных из синхронного канала. Однако у Rust пока нет способа написать цикл for по асинхронной серии элементов, поэтому нам нужно использовать цикл, который мы раньше не видели: условный цикл while let. Это версия цикла для конструкции if let, которую мы видели в разделе Лаконичный поток управления с if let и let else. Цикл будет продолжаться выполнения, пока шаблон (pattern), который он указывает, продолжает соответствовать значению.

Вызов rx.recv производит future, который мы ожидаем (await). Рантайм приостановит future, пока он не будет готов. Как только сообщение arrives, future разрешится (resolve) в Some(message) столько раз, сколько arrives сообщений. Когда канал закрывается, независимо от того, прибыло ли хоть одно сообщение, future вместо этого разрешится в None, чтобы указать, что больше нет значений и, следовательно, нам следует прекратить опрос (polling) — то есть прекратить ожидание (await).

Цикл while let объединяет всё это. Если результат вызова rx.recv().awaitSome(message), мы получаем доступ к сообщению и можем использовать его в теле цикла, как и с if let. Если результат — None, цикл завершается. Каждый раз, когда цикл завершается, он снова достигает точки ожидания (await point), поэтому рантайм снова приостанавливает его до прибытия следующего сообщения.

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

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

В Листинге 17-10 есть только один async-блок, поэтому всё в нём выполняется линейно. Конкуренции (concurrency) всё ещё нет. Все вызовы tx.send происходят, вперемешку со всеми вызовами trpl::sleep и связанными с ними точками ожидания (await points). Только тогда цикл while let получает пройти через какие-либо из точек ожидания (await points) на вызовах recv.

Чтобы получить желаемое поведение, когда задержка сна происходит между каждым сообщением, нам нужно поместить операции tx и rx в их собственные async-блоки, как показано в Листинге 17-11. Затем рантайм может выполнить каждый из них отдельно, используя trpl::join, как в примере с подсчётом. Опять же, мы ожидаем (await) результат вызова trpl::join, а не отдельные futures. Если бы мы ожидали (await) отдельные futures последовательно, мы просто вернулись бы к последовательному потоку — именно того, чего мы не хотим.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx_fut = async {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        trpl::join(tx_fut, rx_fut).await;
    });
}
Listing 17-11: Разделение send и recv в их собственные блоки async и ожидание (await) futures для этих блоков

С обновлённым кодом в Листинге 17-11 сообщения выводятся с интервалом в 500 миллисекунд, а не все сразу через 2 секунды.

Программа всё ещё никогда не завершается, однако, из-за того, как цикл while let взаимодействует с trpl::join:

  • Future, возвращаемый из trpl::join, завершается только после того, как оба future, переданные ему, завершатся.
  • Future tx завершается после того, как он заканчивает спать после отправки последнего сообщения в vals.
  • Future rx не завершится, пока не завершится цикл while let.
  • Цикл while let не закончится, пока ожидание (await) rx.recv не вернёт None.
  • Ожидание (await) rx.recv вернёт None только после того, как другой конец канала будет закрыт.
  • Канал закроется только если мы вызовем rx.close или когда отправительская сторона, tx, будет удалена (dropped).
  • Мы нигде не вызываем rx.close, и tx не будет удалён до конца внешнего async-блока, переданного в trpl::run.
  • Блок не может закончиться, потому что он заблокирован на завершении trpl::join, что возвращает нас в начало этого списка.

Мы могли бы вручную закрыть rx, вызвав rx.close где-то, но это не имеет большого смысла. Остановка после обработки некоторого произвольного количества сообщений заставила бы программу завершиться, но мы могли бы пропустить сообщения. Нам нужен другой способ убедиться, что tx удаляется до конца функции.

Сейчас async-блок, где мы отправляем сообщения, только заимствует (borrows) tx, потому что отправка сообщения не требует владения (ownership), но если бы мы могли переместить (move) tx в этот async-блок, он был бы удалён после завершения этого блока. В разделе главы 13 Захват ссылок или перемещение владения вы узнали, как использовать ключевое слово move с замыканиями, и, как обсуждалось в разделе главы 16 Использование замыканий move с потоками, нам часто нужно перемещать данные в замыкания при работе с потоками. Те же самые основные динамики применимы к async-блокам, поэтому ключевое слово move работает с async-блоками так же, как и с замыканиями.

В Листинге 17-12 мы меняем блок, используемый для отправки сообщений, с async на async move. Когда мы запускаем эту версию кода, она завершается корректно после отправки и получения последнего сообщения.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        trpl::join(tx_fut, rx_fut).await;
    });
}
Listing 17-12: Пересмотр кода из Листинга 17-11, который корректно завершается по окончании

Этот асинхронный канал также является каналом с несколькими производителями, так что мы можем вызвать clone на tx, если хотим отправлять сообщения из нескольких futures, как показано в Листинге 17-13.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(1500)).await;
            }
        };

        trpl::join3(tx1_fut, tx_fut, rx_fut).await;
    });
}
Listing 17-13: Использование нескольких производителей с async-блоками

Сначала мы клонируем tx, создавая tx1 вне первого async-блока. Мы перемещаем (move) tx1 в этот блок, как и делали раньше с tx. Затем, позже, мы перемещаем оригинальный tx в новый async-блок, где отправляем больше сообщений с немного более медленной задержкой. Мы помещаем этот новый async-блок после async-блока для получения сообщений, но он мог бы быть и перед ним. Ключевым является порядок, в котором futures ожидаются (awaited), а не порядок, в котором они создаются.

Оба async-блока для отправки сообщений должны быть блоками async move, чтобы и tx, и tx1 удалялись при завершении этих блоков. В противном случае мы вернёмся к тому же бесконечному циклу, с которого начинали. Наконец, мы переходим с trpl::join на trpl::join3 для обработки дополнительного future.

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

received 'hi'
received 'more'
received 'from'
received 'the'
received 'messages'
received 'future'
received 'for'
received 'you'

Это хорошее начало, но оно ограничивает нас лишь небольшим количеством futures: двумя с join или тремя с join3. Давайте посмотрим, как мы можем работать с большим количеством futures.

Работа с любым количеством фьючерсов

Когда мы перешли от использования двух фьючерсов к трём в предыдущем разделе, нам также пришлось перейти с join на join3. Было бы неудобно каждый раз при изменении количества фьючерсов, которые мы хотим объединить, вызывать другую функцию. К счастью, у нас есть макроформа join!, в которую можно передать произвольное количество аргументов. Она также самостоятельно обрабатывает ожидание фьючерсов. Таким образом, мы можем переписать код из листинга 17-13, используя join! вместо join3, как в листинге 17-14.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        trpl::join!(tx1_fut, tx_fut, rx_fut);
    });
}
Listing 17-14: Использование join! для ожидания нескольких фьючерсов

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

Чтобы проверить все фьючерсы в некоторой коллекции, нам нужно перебрать и объединить все из них. Функция trpl::join_all принимает любой тип, реализующий типаж Iterator, о котором вы узнали в главе 13 Типаж Iterator и метод next, поэтому она кажется идеальным решением. Давайте попробуем поместить наши фьючерсы в вектор и заменить join! на join_all, как показано в листинге 17-15.

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures = vec![tx1_fut, rx_fut, tx_fut];

        trpl::join_all(futures).await;
    });
}
Listing 17-15: Хранение анонимных фьючерсов в векторе и вызов join_all

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

error[E0308]: mismatched types
  --> src/main.rs:45:37
   |
10 |         let tx1_fut = async move {
   |                       ---------- the expected `async` block
...
24 |         let rx_fut = async {
   |                      ----- the found `async` block
...
45 |         let futures = vec![tx1_fut, rx_fut, tx_fut];
   |                                     ^^^^^^ expected `async` block, found a different `async` block
   |
   = note: expected `async` block `{async block@src/main.rs:10:23: 10:33}`
              found `async` block `{async block@src/main.rs:24:22: 24:27}`
   = note: no two async blocks, even if identical, have the same type
   = help: consider pinning your async block and casting it to a trait object

Это может удивить. В конце концов, ни один из асинхронных блоков ничего не возвращает, поэтому каждый из них produces Future<Output = ()>. Помните, что Future — это типаж, и компилятор создаёт уникальный перечисление для каждого асинхронного блока. Вы не можете поместить два разных написанных вручную структуры в Vec, и то же правило применяется к разным перечислениям, сгенерированным компилятором.

Чтобы это заработало, нам нужно использовать объекты типажей, как мы это делали в разделе «Возврат ошибок из функции run» в главе 12. (Мы подробно рассмотрим объекты типажей в главе 18.) Использование объектов типажей позволяет нам рассматривать каждый из анонимных фьючерсов, производимых этими типами, как один и тот же тип, потому что все они реализуют типаж Future.

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

Мы начинаем с обёртывания каждого фьючерса в vec! в Box::new, как показано в листинге 17-16.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures =
            vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];

        trpl::join_all(futures).await;
    });
}
Listing 17-16: Использование Box::new для выравнивания типов фьючерсов в Vec

К сожалению, этот код всё ещё не компилируется. Фактически, мы получаем ту же базовую ошибку, что и раньше, как для второго, так и для третьего вызовов Box::new, а также новые ошибки, касающиеся типажа Unpin. Мы вернёмся к ошибкам Unpin через момент. Сначала давайте исправим ошибки типов в вызовах Box::new, явно аннотируя тип переменной futures (см. листинг 17-17).

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures: Vec<Box<dyn Future<Output = ()>>> =
            vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];

        trpl::join_all(futures).await;
    });
}
Listing 17-17: Исправление остальных ошибок несоответствия типов с помощью явного объявления типа

Это объявление типа немного сложное, поэтому давайте разберём его:

  1. Самый внутренний тип — это сам фьючерс. Мы явно отмечаем, что вывод фьючерса — это единичный тип (), записывая Future<Output = ()>.
  2. Затем мы аннотируем типаж с помощью dyn, чтобы пометить его как динамический.
  3. Вся ссылка на типаж обёрнута в Box.
  4. Наконец, мы явно указываем, что futures — это Vec, содержащий эти элементы.

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

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
   --> src/main.rs:49:24
    |
49  |         trpl::join_all(futures).await;
    |         -------------- ^^^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
    |         |
    |         required by a bound introduced by this call
    |
    = note: consider using the `pin!` macro
            consider using `Box::pin` if you need to access the pinned value outside of the current scope
    = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `join_all`
   --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:105:14
    |
102 | pub fn join_all<I>(iter: I) -> JoinAll<I::Item>
    |        -------- required by a bound in this function
...
105 |     I::Item: Future,
    |              ^^^^^^ required by this bound in `join_all`

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
  --> src/main.rs:49:9
   |
49 |         trpl::join_all(futures).await;
    |         ^^^^^^^^^^^^^^^^^^^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
    |
    = note: consider using the `pin!` macro
            consider using `Box::pin` if you need to access the pinned value outside of the current scope
    = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
  --> src/main.rs:49:33
   |
49 |         trpl::join_all(futures).await;
    |                                 ^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
    |
    = note: consider using the `pin!` macro
            consider using `Box::pin` if you need to access the pinned value outside of the current scope
    = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `async_await` (bin "async_await") due to 3 previous errors

Это много информации, поэтому давайте разберём её. Первая часть сообщения говорит нам, что первый асинхронный блок (src/main.rs:8:23: 20:10) не реализует типаж Unpin и предлагает использовать pin! или Box::pin для его решения. Позже в этой главе мы углубимся в несколько деталей о Pin и Unpin. На данный момент, однако, мы можем просто следовать совету компилятора, чтобы выбраться из тупика. В листинге 17-18 мы начинаем с импорта Pin из std::pin. Затем мы обновляем аннотацию типа для futures, оборачивая каждый Box в Pin. Наконец, мы используем Box::pin для закрепления самих фьючерсов.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::pin::Pin;

// -- snip --

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures: Vec<Pin<Box<dyn Future<Output = ()>>>> =
            vec![Box::pin(tx1_fut), Box::pin(rx_fut), Box::pin(tx_fut)];

        trpl::join_all(futures).await;
    });
}
Listing 17-18: Использование Pin и Box::pin для проверки типа Vec

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

received 'hi'
received 'more'
received 'from'
received 'messages'
received 'the'
received 'for'
received 'future'
received 'you'

Фух!

Здесь есть ещё кое-что, что стоит изучить. Во-первых, использование Pin<Box<T>> добавляет небольшие накладные расходы от размещения этих фьючерсов в куче с помощью Box — и мы делаем это только для того, чтобы типы совпали. Нам на самом деле не нужна аллокация в куче, в конце концов: эти фьючерсы локальны для этой конкретной функции. Как отмечалось ранее, Pin сам по себе является обёртывающим типом, поэтому мы можем получить преимущество наличия одного типа в Vec — исходная причина, по которой мы полезли за Box — без выполнения аллокации в куче. Мы можем использовать Pin напрямую с каждым фьючерсом, используя макрос std::pin::pin.

Однако мы всё ещё должны быть явны о типе закреплённой ссылки; иначе Rust по-прежнему не будет знать, как интерпретировать их как динамические объекты типажей, какими нам нужно, чтобы они были в Vec. Поэтому мы добавляем pin в наш список импортов из std::pin. Затем мы можем pin! каждый фьючерс при его определении и определить futures как Vec, содержащий закреплённые изменяемые ссылки на динамический тип фьючерса, как в листинге 17-19.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::pin::{Pin, pin};

// -- snip --

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = pin!(async move {
            // --snip--
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        });

        let rx_fut = pin!(async {
            // --snip--
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        });

        let tx_fut = pin!(async move {
            // --snip--
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        });

        let futures: Vec<Pin<&mut dyn Future<Output = ()>>> =
            vec![tx1_fut, rx_fut, tx_fut];

        trpl::join_all(futures).await;
    });
}
Listing 17-19: Использование Pin напрямую с макросом pin! для избежания ненужных аллокаций в куче

Мы дошли так далеко, игнорируя тот факт, что у нас могут быть разные типы Output. Например, в листинге 17-20 анонимный фьючерс для a реализует Future<Output = u32>, анонимный фьючерс для b реализует Future<Output = &str>, и анонимный фьючерс для c реализует Future<Output = bool>.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

fn main() {
    trpl::run(async {
        let a = async { 1u32 };
        let b = async { "Hello!" };
        let c = async { true };

        let (a_result, b_result, c_result) = trpl::join!(a, b, c);
        println!("{a_result}, {b_result}, {c_result}");
    });
}
Listing 17-20: Три фьючерса с различными типами

Мы можем использовать trpl::join! для их ожидания, потому что он позволяет передавать в него несколько типов фьючерсов и производит кортеж этих типов. Мы не можем использовать trpl::join_all, потому что она требует, чтобы все переданные фьючерсы имели одинаковый тип. Помните, что эта ошибка и стала началом нашего приключения с Pin!

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

Гонка фьючерсов

Когда мы «объединяем» фьючерсы с помощью семейства функций и макросов join, мы требуем, чтобы все они завершились, прежде чем мы двинемся дальше. Иногда, однако, нам нужно, чтобы какой-нибудь фьючерс из набора завершился, прежде чем мы двинемся дальше — что-то вроде гонки одного фьючерса против другого.

В листинге 17-21 мы снова используем trpl::race для запуска двух фьючерсов, slow и fast, друг против друга.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let slow = async {
            println!("'slow' started.");
            trpl::sleep(Duration::from_millis(100)).await;
            println!("'slow' finished.");
        };

        let fast = async {
            println!("'fast' started.");
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'fast' finished.");
        };

        trpl::race(slow, fast).await;
    });
}
Listing 17-21: Использование race для получения результата того фьючерса, который завершится первым

Каждый фьючерс печатает сообщение, когда начинает работать, делает паузу на некоторое количество времени, вызывая и ожидая sleep, а затем печатает другое сообщение, когда завершается. Затем мы передаём оба, slow и fast, в trpl::race и ждём, пока один из них не завершится. (Результат здесь не слишком удивителен: побеждает fast.) В отличие от того, когда мы использовали race в разделе «Наша первая асинхронная программа», мы просто игнорируем возвращаемый экземпляр Either, потому что всё интересное поведение происходит в теле асинхронных блоков.

Обратите внимание, что если поменять порядок аргументов у race, порядок сообщений «started» изменится, даже если фьючерс fast всегда завершается первым. Это потому, что реализация этой конкретной функции race нечестна. Она всегда запускает фьючерсы, переданные в качестве аргументов, в том порядке, в котором они переданы. Другие реализации честны и будут случайно выбирать, какой фьючерс опрашивать первым. Независимо от того, честна ли реализация race, которую мы используем, однако, один из фьючерсов будет работать до первого await в своём теле, прежде чем другая задача сможет начаться.

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

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

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

Но как вы вернули бы управление рантайму в этих случаях?

Передача управления рантайму

Давайте смоделируем долгую операцию. Листинг 17-22 вводит функцию slow.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        // We will call `slow` here later
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-22: Использование std::thread::sleep для имитации медленных операций

Этот код использует std::thread::sleep вместо trpl::sleep, так что вызов slow заблокирует текущий поток на некоторое количество миллисекунд. Мы можем использовать slow в качестве замены реальным операциям в реальном мире, которые и долгие, и блокирующие.

В листинге 17-23 мы используем slow для имитации выполнения такого рода CPU-ограниченной работы в паре фьючерсов.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        let a = async {
            println!("'a' started.");
            slow("a", 30);
            slow("a", 10);
            slow("a", 20);
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            slow("b", 10);
            slow("b", 15);
            slow("b", 350);
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'b' finished.");
        };

        trpl::race(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-23: Использование thread::sleep для имитации медленных операций

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

'a' started.
'a' ran for 30ms
'a' ran for 10ms
'a' ran for 20ms
'b' started.
'b' ran for 75ms
'b' ran for 10ms
'b' ran for 15ms
'b' ran for 350ms
'a' finished.

Как и в нашем предыдущем примере, race всё ещё завершается, как только a готов. Однако нет переплетения между двумя фьючерсами. Фьючерс a выполняет всю свою работу до того, как будет ожидаться вызов trpl::sleep, затем фьючерс b выполняет всю свою работу до своего собственного вызова trpl::sleep, и наконец фьючерс a завершается. Чтобы позволить обоим фьючерсам делать прогресс между своими медленными задачами, нам нужны точки ожидания, чтобы мы могли вернуть управление рантайму. Это значит, что нам нужно что-то, что мы можем ожидать!

Мы уже видим, как такого рода передача управления происходит в листинге 17-23: если бы мы удалили trpl::sleep в конце фьючерса a, он бы завершился без того, чтобы фьючерс b запускался вообще. Давайте попробуем использовать функцию sleep в качестве отправной точки для того, чтобы операции могли поочерёдно делать прогресс, как показано в листинге 17-24.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        let one_ms = Duration::from_millis(1);

        let a = async {
            println!("'a' started.");
            slow("a", 30);
            trpl::sleep(one_ms).await;
            slow("a", 10);
            trpl::sleep(one_ms).await;
            slow("a", 20);
            trpl::sleep(one_ms).await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            trpl::sleep(one_ms).await;
            slow("b", 10);
            trpl::sleep(one_ms).await;
            slow("b", 15);
            trpl::sleep(one_ms).await;
            slow("b", 350);
            trpl::sleep(one_ms).await;
            println!("'b' finished.");
        };

        trpl::race(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-24: Использование sleep для того, чтобы операции могли поочерёдно делать прогресс

В листинге 17-24 мы добавляем вызовы trpl::sleep с точками ожидания между каждым вызовом slow. Теперь работа двух фьючерсов переплетается:

'a' started.
'a' ran for 30ms
'b' started.
'b' ran for 75ms
'a' ran for 10ms
'b' ran for 10ms
'a' ran for 20ms
'b' ran for 15ms
'a' finished.

Фьючерс a всё ещё работает немного, прежде чем передать управление b, потому что он вызывает slow до того, как когда-либо вызывает trpl::sleep, но после этого фьючерсы меняются каждый раз, когда один из них достигает точки ожидания. В этом случае мы сделали это после каждого вызова slow, но мы могли бы разбить работу каким-либо способом, который для нас наиболее логичен.

На самом деле мы не хотим здесь спать, однако: мы хотим делать прогресс так быстро, как можем. Нам просто нужно вернуть управление рантайму. Мы можем сделать это напрямую, используя функцию yield_now. В листинге 17-25 мы заменяем все эти вызовы sleep на yield_now.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        let a = async {
            println!("'a' started.");
            slow("a", 30);
            trpl::yield_now().await;
            slow("a", 10);
            trpl::yield_now().await;
            slow("a", 20);
            trpl::yield_now().await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            trpl::yield_now().await;
            slow("b", 10);
            trpl::yield_now().await;
            slow("b", 15);
            trpl::yield_now().await;
            slow("b", 350);
            trpl::yield_now().await;
            println!("'b' finished.");
        };

        trpl::race(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-25: Использование yield_now для того, чтобы операции могли поочерёдно делать прогресс

Этот код как яснее отражает фактическое намерение, так и может быть значительно быстрее, чем использование sleep, потому что таймеры, такие как тот, который использует sleep, часто имеют ограничения на то, насколько они могут быть гранулярными. Версия sleep, которую мы используем, например, всегда будет спать как минимум миллисекунду, даже если мы передаём ей Duration в одну наносекунду. Опять же, современные компьютеры быстры: они могут сделать много за одну миллисекунду!

Вы можете увидеть это сами, настроив небольшой бенчмарк, такой как в листинге 17-26. (Это не особенно строгий способ тестирования производительности, но его достаточно, чтобы показать разницу здесь.)

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::{Duration, Instant};

fn main() {
    trpl::run(async {
        let one_ns = Duration::from_nanos(1);
        let start = Instant::now();
        async {
            for _ in 1..1000 {
                trpl::sleep(one_ns).await;
            }
        }
        .await;
        let time = Instant::now() - start;
        println!(
            "'sleep' version finished after {} seconds.",
            time.as_secs_f32()
        );

        let start = Instant::now();
        async {
            for _ in 1..1000 {
                trpl::yield_now().await;
            }
        }
        .await;
        let time = Instant::now() - start;
        println!(
            "'yield' version finished after {} seconds.",
            time.as_secs_f32()
        );
    });
}
Listing 17-26: Сравнение производительности sleep и yield_now

Здесь мы пропускаем весь вывод статуса, передаём Duration в одну наносекунду в trpl::sleep и позволяем каждому фьючерсу работать самому, без переключения между фьючерсами. Затем мы запускаем 1000 итераций и видим, сколько времени занимает фьючерс, использующий trpl::sleep, по сравнению с фьючерсом, использующим trpl::yield_now.

Версия с yield_now намного быстрее!

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

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

Построение наших собственных асинхронных абстракций

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

Листинг 17-27 показывает, как мы ожидаем, что этот timeout будет работать с медленным фьючерсом.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let slow = async {
            trpl::sleep(Duration::from_millis(100)).await;
            "I finished!"
        };

        match timeout(slow, Duration::from_millis(10)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}
Listing 17-27: Использование нашего предполагаемого timeout для запуска медленной операции с ограничением по времени

Давайте реализуем это! Для начала давайте подумаем об API для timeout:

  • Он должен быть асинхронной функцией сам, чтобы мы могли его ожидать.
  • Его первый параметр должен быть фьючерсом для запуска. Мы можем сделать его обобщённым, чтобы он работал с любым фьючерсом.
  • Его второй параметр будет максимальным временем ожидания. Если мы используем Duration, это облегчит передачу в trpl::sleep.
  • Он должен возвращать Result. Если фьючерс завершается успешно, Result будет Ok со значением, произведённым фьючерсом. Если истекает таймаут, Result будет Err с продолжительностью, которую ждал таймаут.

Листинг 17-28 показывает это объявление.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let slow = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finally finished"
        };

        match timeout(slow, Duration::from_millis(10)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}

async fn timeout<F: Future>(
    future_to_try: F,
    max_time: Duration,
) -> Result<F::Output, Duration> {
    // Here is where our implementation will go!
}
Listing 17-28: Определение сигнатуры timeout

Это удовлетворяет нашим целям для типов. Теперь давайте подумаем о поведении, которое нам нужно: мы хотим гонять фьючерс, переданный нам, против продолжительности. Мы можем использовать trpl::sleep, чтобы сделать фьючерс-таймер из продолжительности, и использовать trpl::race для запуска этого таймера с фьючерсом, который передаёт вызывающий.

Мы также знаем, что race нечестна, опрашивая аргументы в том порядке, в котором они переданы. Таким образом, мы передаём future_to_try в race первым, чтобы он получил шанс завершиться, даже если max_time очень короткая продолжительность. Если future_to_try завершается первым, race вернёт Left с выводом из future_to_try. Если таймер завершается первым, race вернёт Right с выводом таймера ().

В листинге 17-29 мы сопоставляем результат ожидания trpl::race.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

use trpl::Either;

// --snip--

fn main() {
    trpl::run(async {
        let slow = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finally finished"
        };

        match timeout(slow, Duration::from_secs(2)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}

async fn timeout<F: Future>(
    future_to_try: F,
    max_time: Duration,
) -> Result<F::Output, Duration> {
    match trpl::race(future_to_try, trpl::sleep(max_time)).await {
        Either::Left(output) => Ok(output),
        Either::Right(_) => Err(max_time),
    }
}
Listing 17-29: Определение timeout с помощью race и sleep

Если future_to_try успешен и мы получаем Left(output), мы возвращаем Ok(output). Если таймер сна истекает вместо этого и мы получаем Right(()), мы игнорируем () с помощью _ и возвращаем Err(max_time) вместо этого.

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

Failed after 2 seconds

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

На практике вы обычно будете работать непосредственно с async и await, и вторично с функциями и макросами, такими как join, join_all, race и так далее. Вам нужно будет доставать pin время от времени, чтобы использовать фьючерсы с этими API.

Мы теперь видели несколько способов работы с несколькими фьючерсами одновременно. Далее мы посмотрим, как мы можем работать с несколькими фьючерсами в последовательности с течением времени с помощью потоков. Вот ещё несколько вещей, которые вы, возможно, захотите рассмотреть сначала:

  • Мы использовали Vec с join_all для ожидания завершения всех фьючерсов в некоторой группе. Как бы вы использовали Vec для обработки группы фьючерсов в последовательности вместо этого? Каковы компромиссы при этом?
  • Посмотрите на тип futures::stream::FuturesUnordered из крейта futures. Чем использование его будет отличаться от использования Vec? (Не беспокойтесь о том, что он из части stream крейта; он отлично работает с любой коллекцией фьючерсов.)

Потоки: Фьючерсы в последовательности

До сих пор в этой главе мы в основном рассматривали отдельные фьючерсы. Единственным большим исключением был асинхронный канал, который мы использовали. Вспомните, как мы использовали приёмник для нашего асинхронного канала ранее в этой главе в разделе «Передача сообщений». Асинхронный метод recv производит последовательность элементов с течением времени. Это экземпляр гораздо более общего паттерна, известного как поток (stream).

Мы видели последовательность элементов в Главе 13, когда рассматривали типаж Iterator в разделе Типаж Iterator и метод next, но между итераторами и асинхронным приёмником канала есть два различия. Первое различие — время: итераторы синхронны, а приёмник канала — асинхронен. Второе — API. При прямой работе с Iterator мы вызываем его синхронный метод next. С потоком trpl::Receiver в частности, мы вызывали асинхронный метод recv. В остальном эти API очень похожи, и это сходство — не совпадение. Поток — это асинхронная форма итерации. В то время как trpl::Receiver конкретно ожидает получения сообщений, универсальный API потока гораздо шире: он предоставляет следующий элемент так же, как Iterator, но асинхронно.

Сходство между итераторами и потоками в Rust означает, что мы можем фактически создать поток из любого итератора. Как и с итератором, мы можем работать с потоком, вызывая его метод next, а затем ожидая результат, как в Листинге 17-30.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

fn main() {
    trpl::run(async {
        let values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
        let iter = values.iter().map(|n| n * 2);
        let mut stream = trpl::stream_from_iter(iter);

        while let Some(value) = stream.next().await {
            println!("The value was: {value}");
        }
    });
}
Listing 17-30: Создание потока из итератора и вывод его значений

Мы начинаем с массива чисел, который преобразуем в итератор, а затем вызываем map, чтобы удвоить все значения. Затем мы преобразуем итератор в поток с помощью функции trpl::stream_from_iter. Далее мы перебираем элементы в потоке по мере их поступления в цикле while let.

К сожалению, при попытке запустить код, он не компилируется, а вместо этого сообщает, что метод next недоступен:

error[E0599]: no method named `next` found for struct `Iter` in the current scope
  --> src/main.rs:10:40
   |
10 |         while let Some(value) = stream.next().await {
   |                                        ^^^^
   |
   = note: the full type name has been written to 'file:///projects/async-await/target/debug/deps/async_await-575db3dd3197d257.long-type-14490787947592691573.txt'
   = note: consider using `--verbose` to print the full type name to the console
   = help: items from traits can only be used if the trait is in scope
help: the following traits which provide `next` are implemented but not in scope; perhaps you want to import one of them
   |
1  + use crate::trpl::StreamExt;
   |
1  + use futures_util::stream::stream::StreamExt;
   |
1  + use std::iter::Iterator;
   |
1  + use std::str::pattern::Searcher;
   |
help: there is a method `try_next` with a similar name
   |
10 |         while let Some(value) = stream.try_next().await {
   |                                        ~~~~~~~~

Как объясняет этот вывод, причина ошибки компилятора в том, что нам нужен правильный типаж в области видимости, чтобы использовать метод next. Учитывая наше обсуждение до сих пор, вы могли бы разумно ожидать, что это будет типаж Stream, но на самом деле это StreamExt. Сокращение от extension (расширение), Ext — общий паттерн в сообществе Rust для расширения одного типажа другим.

Мы объясним типажи Stream и StreamExt немного подробнее в конце главы, но пока всё, что вам нужно знать, — это то, что типаж Stream определяет низкоуровневый интерфейс, который фактически объединяет типажи Iterator и Future. StreamExt предоставляет набор API более высокого уровня поверх Stream, включая метод next, а также другие служебные методы, подобные тем, которые предоставляет типаж Iterator. Stream и StreamExt ещё не являются частью стандартной библиотеки Rust, но большинство крейтов экосистемы используют одно и то же определение.

Исправление ошибки компилятора — добавить инструкцию use для trpl::StreamExt, как в Листинге 17-31.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::StreamExt;

fn main() {
    trpl::run(async {
        let values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
        let iter = values.iter().map(|n| n * 2);
        let mut stream = trpl::stream_from_iter(iter);

        while let Some(value) = stream.next().await {
            println!("The value was: {value}");
        }
    });
}
Listing 17-31: Успешное использование итератора в качестве основы для потока

С всеми этими частями, собранными вместе, этот код работает так, как мы хотим! Более того, теперь, когда у нас есть StreamExt в области видимости, мы можем использовать все его служебные методы, как с итераторами. Например, в Листинге 17-32 мы используем метод filter, чтобы отфильтровать всё, кроме кратных трём и пяти.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::StreamExt;

fn main() {
    trpl::run(async {
        let values = 1..101;
        let iter = values.map(|n| n * 2);
        let stream = trpl::stream_from_iter(iter);

        let mut filtered =
            stream.filter(|value| value % 3 == 0 || value % 5 == 0);

        while let Some(value) = filtered.next().await {
            println!("The value was: {value}");
        }
    });
}
Listing 17-32: Фильтрация потока с помощью метода StreamExt::filter

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

Комбинирование потоков

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

Давайте начнём с создания небольшого потока сообщений в качестве замены потока данных, который мы могли бы видеть из WebSocket или другого протокола реального времени, как показано в Листинге 17-33.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messages = get_messages();

        while let Some(message) = messages.next().await {
            println!("{message}");
        }
    });
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
    for message in messages {
        tx.send(format!("Message: '{message}'")).unwrap();
    }

    ReceiverStream::new(rx)
}
Listing 17-33: Использование приёмника rx как ReceiverStream

Сначала мы создаём функцию под названием get_messages, которая возвращает impl Stream<Item = String>. Для её реализации мы создаём асинхронный канал, перебираем первые 10 букв английского алфавита и отправляем их через канал.

Мы также используем новый тип: ReceiverStream, который преобразует приёмник rx из trpl::channel в Stream с методом next. Назад в main мы используем цикл while let, чтобы вывести все сообщения из потока.

Когда мы запускаем этот код, мы получаем именно те результаты, которые ожидаем:

Message: 'a'
Message: 'b'
Message: 'c'
Message: 'd'
Message: 'e'
Message: 'f'
Message: 'g'
Message: 'h'
Message: 'i'
Message: 'j'

Опять же, мы могли бы сделать это с обычным API Receiver или даже с обычным API Iterator, так что давайте добавим функцию, требующую потоков: добавление таймаута, который применяется ко всем элементам потока, и задержки к отправляемым элементам, как показано в Листинге 17-34.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};
use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messages =
            pin!(get_messages().timeout(Duration::from_millis(200)));

        while let Some(result) = messages.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
    for message in messages {
        tx.send(format!("Message: '{message}'")).unwrap();
    }

    ReceiverStream::new(rx)
}
Listing 17-34: Использование метода StreamExt::timeout для установки ограничения по времени на элементы в потоке

Мы начинаем с добавления таймаута к потоку с помощью метода timeout, который поступает из типажа StreamExt. Затем мы обновляем тело цикла while let, потому что поток теперь возвращает Result. Вариант Ok указывает, что сообщение прибыло вовремя; вариант Err указывает, что таймаут истёк до того, как какое-либо сообщение прибыло. Мы используем match для этого результата и либо выводим сообщение при успешном получении, либо выводим уведомление о таймауте. Наконец, обратите внимание, что мы фиксируем сообщения после применения к ним таймаута, потому что вспомогательная функция таймаута производит поток, который нужно зафиксировать, чтобы его можно было опросить.

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

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messages =
            pin!(get_messages().timeout(Duration::from_millis(200)));

        while let Some(result) = messages.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-35: Отправка сообщений через tx с асинхронной задержкой, не делая get_messages асинхронной функцией

В get_messages мы используем метод итератора enumerate с массивом messages, чтобы получить индекс каждого отправляемого элемента вместе с самим элементом. Затем мы применяем 100-миллисекундную задержку к элементам с чётными индексами и 300-миллисекундную задержку к элементам с нечётными индексами, чтобы имитировать различные задержки, которые мы могли бы видеть из потока сообщений в реальном мире. Поскольку наш таймаут составляет 200 миллисекунд, это должно повлиять на половину сообщений.

Чтобы спать между сообщениями в функции get_messages без блокировки, нам нужно использовать async. Однако мы не можем сделать get_messages самой асинхронной функцией, потому что тогда мы вернём Future<Output = Stream<Item = String>> вместо Stream<Item = String>>. Вызывающий должен будет дождаться get_messages сам, чтобы получить доступ к потоку. Но помните: всё в данном фьючерсе происходит линейно; конкурентность происходит между фьючерсами. Ожидание get_messages потребовало бы, чтобы оно отправило все сообщения, включая задержку сна между каждым сообщением, прежде чем вернуть поток приёмника. В результате таймаут был бы бесполезен. Все задержки в потоке произошли бы до того, как поток стал бы вообще доступен.

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

Примечание: Вызов spawn_task таким образом работает, потому что мы уже настроили нашу среду выполнения; если бы мы этого не сделали, это вызвало бы панику. Другие реализации выбирают разные компромиссы: они могут порождать новую среду выполнения и избегать паники, но получат немного дополнительных накладных расходов, или они могут просто не предоставлять автономный способ порождать задачи без ссылки на среду выполнения. Убедитесь, что вы знаете, какой компромисс выбрала ваша среда выполнения, и пишите код соответственно!

Теперь наш код имеет гораздо более интересный результат. Между каждой другой парой сообщений возникает ошибка Problem: Elapsed(()).

Message: 'a'
Problem: Elapsed(())
Message: 'b'
Message: 'c'
Problem: Elapsed(())
Message: 'd'
Message: 'e'
Problem: Elapsed(())
Message: 'f'
Message: 'g'
Problem: Elapsed(())
Message: 'h'
Message: 'i'
Problem: Elapsed(())
Message: 'j'

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

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

Объединение потоков

Сначала давайте создадим другой поток, который будет излучать элемент каждую миллисекунду, если мы позволим ему работать напрямую. Для простоты мы можем использовать функцию sleep для отправки сообщения с задержкой и объединить её с тем же подходом, который мы использовали в get_messages, создавая поток из канала. Разница в том, что на этот раз мы будем отправлять обратно счётчик прошедших интервалов, поэтому тип возвращаемого значения будет impl Stream<Item = u32>, и мы можем назвать функцию get_intervals (см. Листинг 17-36).

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messages =
            pin!(get_messages().timeout(Duration::from_millis(200)));

        while let Some(result) = messages.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;
            tx.send(count).unwrap();
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-36: Создание потока со счётчиком, который будет излучаться каждую миллисекунду

Мы начинаем с определения count в задаче. (Мы могли бы определить его и вне задачи, но ограничить область видимости любой данной переменной яснее.) Затем мы создаём бесконечный цикл. Каждая итерация цикла асинхронно спит одну миллисекунду, увеличивает счётчик, а затем отправляет его через канал. Поскольку всё это обёрнуто в задачу, созданную spawn_task, всё это — включая бесконечный цикл — будет очищено вместе со средой выполнения.

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

Теперь, обратно в асинхронном блоке нашей функции main, мы можем попытаться объединить потоки messages и intervals, как показано в Листинге 17-37.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals();
        let merged = messages.merge(intervals);

        while let Some(result) = merged.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;
            tx.send(count).unwrap();
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-37: Попытка объединить потоки messages и intervals

Мы начинаем с вызова get_intervals. Затем мы объединяем потоки messages и intervals с помощью метода merge, который объединяет несколько потоков в один поток, производящий элементы из любого из исходных потоков, как только элементы становятся доступными, не накладывая никакого конкретного порядка. Наконец, мы перебираем этот объединённый поток вместо messages.

На данный момент ни messages, ни intervals не нужно фиксировать или делать изменяемыми, потому как оба будут объединены в один поток merged. Однако этот вызов merge не компилируется! (Также не компилируется вызов next в цикле while let, но мы к этому вернёмся.) Это потому, что два потока имеют разные типы. Поток messages имеет тип Timeout<impl Stream<Item = String>>, где Timeout — это тип, который реализует Stream для вызова timeout. Поток intervals имеет тип impl Stream<Item = u32>. Чтобы объединить эти два потока, нам нужно преобразовать один из них, чтобы он соответствовал другому. Мы переработаем поток интервалов, потому что сообщения уже в том базовом формате, который мы хотим, и должны обрабатывать ошибки таймаута (см. Листинг 17-38).

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals()
            .map(|count| format!("Interval: {count}"))
            .timeout(Duration::from_secs(10));
        let merged = messages.merge(intervals);
        let mut stream = pin!(merged);

        while let Some(result) = stream.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;
            tx.send(count).unwrap();
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-38: Выравнивание типа потока intervals с типом потока messages

Сначала мы можем использовать вспомогательный метод map, чтобы преобразовать intervals в строку. Во-вторых, нам нужно сопоставить Timeout из messages. Поскольку мы на самом деле не хотим таймаут для intervals, мы можем просто создать таймаут, который длиннее, чем другие используемые нами длительности. Здесь мы создаём 10-секундный таймаут с помощью Duration::from_secs(10). Наконец, нам нужно сделать stream изменяемым, чтобы вызовы next в цикле while let могли перебирать поток, и зафиксировать его, чтобы это было безопасно сделать. Это почти приводит нас туда, где нам нужно быть. Всё типизируется. Если вы запустите это, однако, будет две проблемы. Во-первых, он никогда не остановится! Вам нужно будет остановить его с помощью ctrl-c. Во-вторых, сообщения из английского алфавита будут затеряны среди всех сообщений счётчика интервалов:

--snip--
Interval: 38
Interval: 39
Interval: 40
Message: 'a'
Interval: 41
Interval: 42
Interval: 43
--snip--

Листинг 17-39 показывает один способ решения этих последних двух проблем.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals()
            .map(|count| format!("Interval: {count}"))
            .throttle(Duration::from_millis(100))
            .timeout(Duration::from_secs(10));
        let merged = messages.merge(intervals).take(20);
        let mut stream = pin!(merged);

        while let Some(result) = stream.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;
            tx.send(count).unwrap();
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-39: Использование throttle и take для управления объединёнными потоками

Сначала мы используем метод throttle на потоке intervals, чтобы он не перегружал поток messages. Ограничение частоты (throttling) — это способ ограничения скорости, с которой функция будет вызываться — или, в этом случае, как часто поток будет опрашиваться. Раз в 100 миллисекунд должно подойти, потому что примерно так часто прибывают наши сообщения.

Чтобы ограничить количество элементов, которые мы примем из потока, мы применяем метод take к объединённому потоку merged, потому что хотим ограничить окончательный вывод, а не только один поток или другой.

Теперь, когда мы запускаем программу, она останавливается после извлечения 20 элементов из потока, и интервалы не перегружают сообщения. Мы также не получаем Interval: 100 или Interval: 200 и так далее, а вместо этого получаем Interval: 1, Interval: 2 и так далее — даже хотя у нас есть исходный поток, который может производить событие каждую миллисекунду. Это потому, что вызов throttle производит новый поток, который оборачивает исходный поток так, что исходный поток опрашивается только с частотой ограничения, а не на своей собственной «родной» частоте. У нас нет кучи необработанных сообщений интервалов, которые мы выбираем игнорировать. Вместо этого мы никогда не производим эти сообщения интервалов с самого начала! Это присущая Rust «ленивость» фьючерсов, позволяющая нам выбирать наши характеристики производительности.

Interval: 1
Message: 'a'
Interval: 2
Interval: 3
Problem: Elapsed(())
Interval: 4
Message: 'b'
Interval: 5
Message: 'c'
Interval: 6
Interval: 7
Problem: Elapsed(())
Interval: 8
Message: 'd'
Interval: 9
Message: 'e'
Interval: 10
Interval: 11
Problem: Elapsed(())
Interval: 12

Осталось последнее, что нам нужно обработать: ошибки! С обоими этими каналовыми потоками вызовы send могут завершиться ошибкой, когда другая сторона канала закрывается — и это просто вопрос того, как среда выполнения выполняет фьючерсы, составляющие поток. До сих пор мы игнорировали эту возможность, вызывая unwrap, но в хорошо написанном приложении мы должны явно обработать ошибку, как минимум завершив цикл, чтобы не пытаться отправлять больше сообщений. Листинг 17-40 показывает простую стратегию ошибок: вывести проблему, а затем break из циклов.

extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals()
            .map(|count| format!("Interval #{count}"))
            .throttle(Duration::from_millis(500))
            .timeout(Duration::from_secs(10));
        let merged = messages.merge(intervals).take(20);
        let mut stream = pin!(merged);

        while let Some(result) = stream.next().await {
            match result {
                Ok(item) => println!("{item}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    });
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];

        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            if let Err(send_error) = tx.send(format!("Message: '{message}'")) {
                eprintln!("Cannot send message '{message}': {send_error}");
                break;
            }
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;

            if let Err(send_error) = tx.send(count) {
                eprintln!("Could not send interval {count}: {send_error}");
                break;
            };
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-40: Обработка ошибок и завершение циклов

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

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

Более детальный взгляд на типажи для асинхронного программирования

На протяжении всей главы мы использовали типажи Future, Pin, Unpin, Stream и StreamExt различными способами. Однако до сих пор мы избегали углубления в детали их работы или того, как они связаны между собой, что в большинстве случаев достаточно для повседневной работы с Rust. Иногда, однако, возникают ситуации, когда требуется понять некоторые из этих детальнее. В этом разделе мы рассмотрим их ровно настолько, насколько это необходимо для таких сценариев, оставляя действительно глубокое погружение для другой документации.

Типаж Future

Давайте начнём с более пристального взгляда на то, как работает типаж Future. Вот как Rust определяет его:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

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

Во-первых, ассоциированный тип Output у Future указывает, к чему разрешается future. Это аналогично ассоциированному типу Item для типажа Iterator. Во-вторых, Future также имеет метод poll, который принимает специальную ссылку Pin для своего параметра self и изменяемую ссылку на тип Context, а возвращает Poll<Self::Output>. Мы немного поговорим о Pin и Context позже. Пока сосредоточимся на том, что возвращает метод, на типе Poll:

#![allow(unused)]
fn main() {
enum Poll<T> {
    Ready(T),
    Pending,
}
}

Этот тип Poll похож на Option. У него есть один вариант со значением, Ready(T), и один без значения, Pending. Однако Poll означает нечто совершенно иное, чем Option! Вариант Pending указывает, что future всё ещё выполняет работу, поэтому вызывающей стороне потребуется проверить его снова позже. Вариант Ready указывает, что future завершил свою работу и значение T доступно.

Примечание: Для большинства futures вызывающая сторона не должна вызывать poll снова после того, как future вернул Ready. Многие futures вызовут панику, если их опросить снова после того, как они стали готовыми. Futures, которые безопасно опрашивать повторно, явно об этом сообщат в своей документации. Это похоже на поведение Iterator::next.

Когда вы видите код, использующий await, Rust компилирует его под капотом в код, который вызывает poll. Если вы посмотрите на Листинг 17-4, где мы выводили заголовок страницы для одного URL после его разрешения, Rust компилирует это во что-то вроде (хотя и не точно) этого:

match page_title(url).poll() {
    Ready(page_title) => match page_title {
        Some(title) => println!("The title for {url} was {title}"),
        None => println!("{url} had no title"),
    }
    Pending => {
        // Но что здесь должно быть?
    }
}

Что нам делать, когда future всё ещё Pending? Нам нужен способ попробовать снова, и снова, и снова, пока future наконец не станет готовым. Другими словами, нам нужен цикл:

let mut page_title_fut = page_title(url);
loop {
    match page_title_fut.poll() {
        Ready(value) => match page_title {
            Some(title) => println!("The title for {url} was {title}"),
            None => println!("{url} had no title"),
        }
        Pending => {
            // продолжить
        }
    }
}

Если бы Rust скомпилировал это в точно такой код, каждый await был бы блокирующим — прямо противоположным тому, к чему мы стремились! Вместо этого Rust гарантирует, что цикл может передать управление чему-то, что может приостановить работу над этим future, чтобы поработать над другими futures, а затем проверить этот снова позже. Как мы видели, этим “чем-то” является среда выполнения async, и эта работа по планированию и координации — одна из её главных задач.

Ранее в главе мы описали ожидание вызова rx.recv. Вызов recv возвращает future, и ожидание этого future опрашивает его. Мы отметили, что среда выполнения приостановит future до тех пор, пока он не будет готов с либо Some(message), либо None при закрытии канала. С нашим более глубоким пониманием типажа Future и, в частности, Future::poll, мы можем увидеть, как это работает. Среда выполнения знает, что future не готов, когда он возвращает Poll::Pending. И наоборот, среда выполнения знает, что future готов и продвигает его, когда poll возвращает Poll::Ready(Some(message)) или Poll::Ready(None).

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

Типажи Pin и Unpin

Когда мы представили идею закрепления (pinning) в Листинге 17-16, мы столкнулись с очень запутанным сообщением об ошибке. Вот соответствующая часть его снова:

error[E0277]: `{async block@src/main.rs:10:23: 10:33}` cannot be unpinned
  --> src/main.rs:48:33
   |
48 |         trpl::join_all(futures).await;
   |                                 ^^^^^ the trait `Unpin` is not implemented for `{async block@src/main.rs:10:23: 10:33}`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<{async block@src/main.rs:10:23: 10:33}>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

Это сообщение об ошибке говорит нам не только о том, что нужно закрепить значения, но и о том, почему закрепление требуется. Функция trpl::join_all возвращает структуру под названием JoinAll. Эта структура обобщена (generic) над типом F, который ограничен реализацией типажа Future. Прямое ожидание future с помощью await неявно закрепляет future. Именно поэтому нам не нужно использовать pin! везде, где мы хотим ожидать futures.

Однако здесь мы не ожидаем future напрямую. Вместо этого мы создаём новый future, JoinAll, передавая коллекцию futures функции join_all. Сигнатура для join_all требует, чтобы типы элементов в коллекции все реализовывали типаж Future, а Box<T> реализует Future только если обёрнутый им T является future, реализующим типаж Unpin.

Это многое стоит усвоить! Чтобы действительно это понять, давайте углубимся немного дальше в то, как на самом деле работает типаж Future, в частности вокруг закрепления (pinning).

Посмотрите снова на определение типажа Future:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    // Required method
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

Параметр cx и его тип Context — это ключ к тому, как среда выполнения на самом деле знает, когда проверять любой данный future, оставаясь при этом ленивой. Снова, детали того, как это работает, выходят за рамки этой главы, и вам, как правило, нужно думать об этом только при написании собственной реализации Future. Мы сосредоточимся вместо этого на типе для self, так как это первый раз, когда мы видим метод, где self имеет аннотацию типа. Аннотация типа для self работает как аннотации типа для других параметров функции, но с двумя ключевыми отличиями:

  • Она говорит Rust, каким типом должен быть self для вызова метода.
  • Она не может быть просто любым типом. Она ограничена типом, для которого реализован метод, ссылкой или умным указателем на этот тип, или обёрткой Pin вокруг ссылки на этот тип.

Мы увидим больше об этом синтаксисе в Главе 18. Пока достаточно знать, что если мы хотим опросить future, чтобы проверить, является ли он Pending или Ready(Output), нам нужна изменяемая ссылка, обёрнутая в Pin, на тип.

Pin — это обёртка для указательноподобных типов, таких как &, &mut, Box и Rc. (Технически, Pin работает с типами, которые реализуют типажи Deref или DerefMut, но это практически эквивалентно работе только с указателями.) Pin не является указателем сам по себе и не имеет собственного поведения, как Rc и Arc со счётчиком ссылок; это чисто инструмент, который компилятор может использовать для принуждения ограничений на использование указателей.

Вспоминая, что await реализован на основе вызовов poll, начинаешь объяснять сообщение об ошибке, которое мы видели ранее, но оно было в терминах Unpin, а не Pin. Так как же именно Pin связан с Unpin, и зачем Future нужен self в типе Pin для вызова poll?

Помните из предыдущей части главы, что серия точек ожидания (await points) в future компилируется в конечный автомат (state machine), и компилятор гарантирует, что этот конечный автомат следует всем обычным правилам Rust относительно безопасности, включая заимствование и владение. Чтобы это работало, Rust смотрит, какие данные нужны между одной точкой ожидания и либо следующей точкой ожидания, либо концом async-блока. Затем он создаёт соответствующий вариант в скомпилированном конечном автомате. Каждый вариант получает доступ, который ему нужен, к данным, которые будут использоваться в этом разделе исходного кода, либо путём взятия владения этими данными, либо путём получения изменяемой или неизменяемой ссылки на них.

Пока что всё хорошо: если мы что-то напутаем с владением или ссылками в данном async-блоке, проверка заимствований (borrow checker) сообщит нам об этом. Когда мы хотим переместить future, соответствующий этому блоку — например, переместить его в Vec для передачи в join_all — всё становится сложнее.

Когда мы перемещаем future — будь то помещая его в структуру данных для использования в качестве итератора с join_all или возвращая его из функции — это на самом деле означает перемещение конечного автомата, который Rust создаёт для нас. И в отличие от большинства других типов в Rust, futures, которые Rust создаёт для async-блоков, могут в итоге иметь ссылки на сами себя в полях любого данного варианта, как показано на упрощённой иллюстрации на Рисунке 17-4.

A single-column, three-row table representing a future, fut1, which has data values 0 and 1 in the first two rows and an arrow pointing from the third row back to the second row, representing an internal reference within the future.
Рисунок 17-4: Тип данных с самореференцией.

Однако по умолчанию любой объект, имеющий ссылку на себя, небезопасен для перемещения, потому что ссылки всегда указывают на фактический адрес памяти того, на что они ссылаются (см. Рисунок 17-5). Если вы переместите саму структуру данных, эти внутренние ссылки будут указывать на старое местоположение. Однако это местоположение памяти теперь недействительно. Во-первых, его значение не будет обновляться при внесении изменений в структуру данных. Во-вторых — и что более важно — компьютер теперь может свободно повторно использовать эту память для других целей! Вы можете в итоге читать совершенно несвязанные данные позже.

Two tables, depicting two futures, fut1 and fut2, each of which has one column and three rows, representing the result of having moved a future out of fut1 into fut2. The first, fut1, is grayed out, with a question mark in each index, representing unknown memory. The second, fut2, has 0 and 1 in the first and second rows and an arrow pointing from its third row back to the second row of fut1, representing a pointer that is referencing the old location in memory of the future before it was moved.
Рисунок 17-5: Небезопасный результат перемещения типа данных с самореференцией

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

Pin основывается на этом, чтобы дать нам именно ту гарантию, которая нам нужна. Когда мы закрепляем (pin) значение, обернув указатель на это значение в Pin, оно больше не может перемещаться. Таким образом, если у вас есть Pin<Box<SomeType>>, вы на самом деле закрепляете значение SomeType, а не указатель Box. Рисунок 17-6 иллюстрирует этот процесс.

Three boxes laid out side by side. The first is labeled “Pin”, the second “b1”, and the third “pinned”. Within “pinned” is a table labeled “fut”, with a single column; it represents a future with cells for each part of the data structure. Its first cell has the value “0”, its second cell has an arrow coming out of it and pointing to the fourth and final cell, which has the value “1” in it, and the third cell has dashed lines and an ellipsis to indicate there may be other parts to the data structure. All together, the “fut” table represents a future which is self-referential. An arrow leaves the box labeled “Pin”, goes through the box labeled “b1” and has terminates inside the “pinned” box at the “fut” table.
Рисунок 17-6: Закрепление `Box`, который указывает на тип future с самореференцией.

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

Four boxes laid out in three rough columns, identical to the previous diagram with a change to the second column. Now there are two boxes in the second column, labeled “b1” and “b2”, “b1” is grayed out, and the arrow from “Pin” goes through “b2” instead of “b1”, indicating that the pointer has moved from “b1” to “b2”, but the data in “pinned” has not moved.
Рисунок 17-7: Перемещение `Box`, который указывает на тип future с самореференцией.

Однако большинство типов абсолютно безопасны для перемещения, даже если они оказываются за обёрткой Pin. Нам нужно думать о закреплении только когда у элементов есть внутренние ссылки. Примитивные значения, такие как числа и булевы значения, безопасны, потому что они явно не имеют никаких внутренних ссылок. То же самое касается большинства типов, с которыми вы обычно работаете в Rust. Вы можете перемещать Vec, например, не беспокоясь об этом. Учитывая только то, что мы видели до сих пор, если у вас есть Pin<Vec<String>>, вам пришлось бы делать всё через безопасные, но ограничительные API, предоставляемые Pin, даже если Vec<String> всегда безопасен для перемещения, если на него нет других ссылок. Нам нужен способ сообщить компилятору, что безопасно перемещать элементы вокруг в случаях подобных этому — и вот где появляется Unpin.

Unpin — это маркерный типаж (marker trait), подобный типажам Send и Sync, которые мы видели в Главе 16, и, таким образом, не имеет собственной функциональности. Маркерные типажи существуют только для того, чтобы сообщить компилятору, что безопасно использовать тип, реализующий данный типаж, в определённом контексте. Unpin сообщает компилятору, что данный тип не должен соблюдать никаких гарантий относительно того, можно ли безопасно перемещать рассматриваемое значение.

Подобно Send и Sync, компилятор реализует Unpin автоматически для всех типов, где может доказать, что это безопасно. Особый случай, снова подобно Send и Sync, — это когда Unpin не реализован для типа. Нотация для этого — impl !Unpin for SomeType, где SomeType — это имя типа, который действительно должен соблюдать эти гарантии, чтобы быть безопасным всякий раз, когда указатель на этот тип используется в Pin.

Другими словами, есть две вещи, которые нужно помнить о взаимосвязи между Pin и Unpin. Во-первых, Unpin — это “нормальный” случай, а !Unpin — особый случай. Во-вторых, реализует ли тип Unpin или !Unpin имеет значение только когда вы используете закреплённый указатель на этот тип, такой как Pin<&mut SomeType>.

Чтобы это конкретизировать, подумайте о String: у него есть длина и символы Юникода, которые его составляют. Мы можем обернуть String в Pin, как видно на Рисунке 17-8. Однако String автоматически реализует Unpin, как и большинство других типов в Rust.

Concurrent work flow
Рисунок 17-8: Закрепление `String`; пунктирная линия указывает, что `String` реализует типаж `Unpin`, и, таким образом, не закреплён.

В результате мы можем делать то, что было бы незаконно, если бы String реализовывал !Unpin вместо этого, например, заменять одну строку на другую в том же самом месте в памяти, как на Рисунке 17-9. Это не нарушает контракт Pin, потому что String не имеет внутренних ссылок, которые делают его небезопасным для перемещения! Именно поэтому он реализует Unpin, а не !Unpin.

Concurrent work flow
Рисунок 17-9: Замена `String` на совершенно другой `String` в памяти.

Теперь мы знаем достаточно, чтобы понять ошибки, сообщённые для того вызова join_all из Листинга 17-17. Мы изначально пытались переместить futures, производимые async-блоками, в Vec<Box<dyn Future<Output = ()>>>, но, как мы видели, эти futures могут иметь внутренние ссылки, поэтому они не реализуют Unpin. Им нужно быть закреплёнными, и тогда мы можем передать тип Pin в Vec, будучи уверенными, что базовые данные в futures не будут перемещены.

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

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

Специфика того, как работают Pin и Unpin, и правила, которые они должны соблюдать, подробно рассматриваются в API-документации для std::pin, так что, если вы заинтересованы в изучении большего, это отличное место для начала.

Если вы хотите понять, как всё работает под капотом, ещё более подробно, см. Главы 2 и 4 книги Асинхронное программирование на Rust.

Типаж Stream

Теперь, когда у вас более глубокое понимание типажей Future, Pin и Unpin, мы можем обратить наше внимание на типаж Stream. Как вы узнали ранее в главе, потоки (streams) похожи на асинхронные итераторы. В отличие от Iterator и Future, однако, Stream не имеет определения в стандартной библиотеке на момент написания, но существует очень распространённое определение из крейта futures, используемое во всей экосистеме.

Давайте пересмотрим определения типажей Iterator и Future перед тем, как посмотреть, как типаж Stream мог бы объединить их вместе. Из Iterator у нас есть идея последовательности: его метод next предоставляет Option<Self::Item>. Из Future у нас есть идея готовности во времени: его метод poll предоставляет Poll<Self::Output>. Чтобы представить последовательность элементов, которые становятся готовыми со временем, мы определяем типаж Stream, который объединяет эти особенности:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;

    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>
    ) -> Poll<Option<Self::Item>>;
}
}

Типаж Stream определяет ассоциированный тип под названием Item для типа элементов, производимых потоком. Это похоже на Iterator, где может быть от нуля до многих элементов, и в отличие от Future, где всегда есть один Output, даже если это тип единицы ().

Stream также определяет метод для получения этих элементов. Мы называем его poll_next, чтобы было ясно, что он опрашивает так же, как Future::poll, и производит последовательность элементов так же, как Iterator::next. Его тип возвращаемого значения объединяет Poll с Option. Внешний тип — Poll, потому что его нужно проверять на готовность, как future. Внутренний тип — Option, потому что он должен сигнализировать, есть ли ещё сообщения, как итератор.

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

В примере, который мы видели в разделе о потоках, однако, мы не использовали poll_next или Stream, а вместо этого использовали next и StreamExt. Мы могли работать непосредственно с API poll_next, вручную написав свои конечные автоматы Stream, конечно, точно так же, как мы могли работать с futures напрямую через их метод poll. Однако использование await гораздо приятнее, и типаж StreamExt предоставляет метод next, чтобы мы могли сделать именно это:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;
    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Option<Self::Item>>;
}

trait StreamExt: Stream {
    async fn next(&mut self) -> Option<Self::Item>
    where
        Self: Unpin;

    // other methods...
}
}

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

fn next(&mut self) -> Next<'_, Self> where Self: Unpin;

Этот тип Next — это struct, который реализует Future и позволяет нам назвать время жизни ссылки на self с помощью Next<'_, Self>, чтобы await мог работать с этим методом.

Типаж StreamExt — это также домен для всех интересных методов, доступных для использования с потоками. StreamExt автоматически реализуется для каждого типа, который реализует Stream, но эти типажи определены отдельно, чтобы позволить сообществу итерировать удобные API без влияния на фундаментальный типаж.

В версии StreamExt, используемой в крейте trpl, типаж не только определяет метод next, но и предоставляет реализацию по умолчанию для next, которая правильно обрабатывает детали вызова Stream::poll_next. Это означает, что даже когда вам нужно написать свой собственный тип данных для потока, вам только нужно реализовать Stream, и тогда любой, кто использует ваш тип данных, сможет автоматически использовать StreamExt и его методы с ним.

На этом мы закончим охват деталей низкого уровня по этим типажам. Чтобы завершить, давайте рассмотрим, как futures (включая потоки), задачи (tasks) и потоки (threads) все связаны вместе!

Собираем всё вместе: Futures, задачи и потоки

Как мы видели в Главе 16, потоки предоставляют один из подходов к конкурентности. В этой главе мы рассмотрели другой подход: использование async с futures и потоками (streams). Если вы задаётесь вопросом, когда выбирать один метод над другим, ответ: это зависит! И во многих случаях выбор — не потоки или async, а потоки и async.

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

Модель async предоставляет другой — и в конечном счёте дополняющий — набор компромиссов. В модели async конкурентные операции не требуют своих собственных потоков. Вместо этого они могут выполняться в задачах (tasks), как когда мы использовали trpl::spawn_task для запуска работы из синхронной функции в разделе о потоках (streams). Задача похожа на поток, но вместо управления операционной системой она управляется кодом на уровне библиотеки: рантаймом (runtime).

В предыдущем разделе мы видели, что можем построить поток (stream), используя асинхронный канал и порождая асинхронную задачу, которую можно вызвать из синхронного кода. Мы можем сделать то же самое с потоком (thread). В Листинге 17-40 мы использовали trpl::spawn_task и trpl::sleep. В Листинге 17-41 мы заменяем их на API thread::spawn и thread::sleep из стандартной библиотеки в функции get_intervals.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, thread, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals()
            .map(|count| format!("Interval #{count}"))
            .throttle(Duration::from_millis(500))
            .timeout(Duration::from_secs(10));
        let merged = messages.merge(intervals).take(20);
        let mut stream = pin!(merged);

        while let Some(result) = stream.next().await {
            match result {
                Ok(item) => println!("{item}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    });
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];

        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            if let Err(send_error) = tx.send(format!("Message: '{message}'")) {
                eprintln!("Cannot send message '{message}': {send_error}");
                break;
            }
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    // This is *not* `trpl::spawn` but `std::thread::spawn`!
    thread::spawn(move || {
        let mut count = 0;
        loop {
            // Likewise, this is *not* `trpl::sleep` but `std::thread::sleep`!
            thread::sleep(Duration::from_millis(1));
            count += 1;

            if let Err(send_error) = tx.send(count) {
                eprintln!("Could not send interval {count}: {send_error}");
                break;
            };
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-41: Использование API std::thread вместо асинхронных API trpl для функции get_intervals

Если вы запустите этот код, вывод будет идентичен выводу Листинга 17-40. И обратите внимание, как мало изменяется здесь с точки зрения вызывающего кода. Более того, даже если одна из наших функций породила асинхронную задачу в рантайме, а другая — поток ОС, результирующие потоки (streams) не были затронуты этими различиями.

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

Однако есть причина, по которой эти API так похожи. Потоки действуют как граница для наборов синхронных операций; конкурентность возможна между потоками. Задачи действуют как граница для наборов асинхронных операций; конкурентность возможна как между, так и внутри задач, потому что задача может переключаться между futures в своём теле. Наконец, futures — это наиболее гранулярная единица конкурентности в Rust, и каждый future может представлять дерево других futures. Рантайм — а именно его исполнитель (executor) — управляет задачами, а задачи управляют futures. В этом отношении задачи похожи на лёгкие, управляемые рантаймом потоки с дополнительными возможностями, которые возникают из-за управления рантаймом, а не операционной системой.

Это не означает, что асинхронные задачи всегда лучше потоков (или наоборот). Конкурентность с потоками в некоторых отношениях является более простой программистской моделью, чем конкурентность с async. Это может быть силой или слабостью. Потоки несколько “fire and forget” (запустил и забыл); у них нет родного эквивалента future, поэтому они просто выполняются до завершения без прерываний, кроме как самой операционной системой. То есть у них нет встроенной поддержки внутризадачной (intratask) конкурентности, как у futures. Потоки в Rust также не имеют механизмов для отмены (cancellation) — темы, которую мы явно не охватывали в этой главе, но которая подразумевалась тем фактом, что всякий раз, когда мы завершали future, его состояние корректно очищалось.

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

Таким образом, задачи дают нам дополнительный контроль над futures, позволяя выбирать, где и как их группировать. И оказывается, что потоки и задачи часто очень хорошо работают вместе, потому что задачи могут (по крайней мере, в некоторых рантаймах) перемещаться между потоками. Фактически, под капотом рантайм, которым мы пользовались — включая функции spawn_blocking и spawn_task — по умолчанию многопоточный! Многие рантаймы используют подход под названием work stealing (кража работы), чтобы прозрачно перемещать задачи между потоками на основе текущей загрузки потоков, улучшая общую производительность системы. Этот подход фактически требует и потоков, и задач, а следовательно, и futures.

Думая о том, какой метод использовать, учитывайте эти эмпирические правила:

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

И если вам нужны и параллелизм, и конкурентность, вам не нужно выбирать между потоками и async. Вы можете использовать их вместе свободно, позволяя каждому играть ту роль, для которой он лучше всего подходит. Например, Листинг 17-42 показывает довольно распространённый пример такой смеси в реальном коде на Rust.

Filename: src/main.rs
extern crate trpl; // for mdbook test

use std::{thread, time::Duration};

fn main() {
    let (tx, mut rx) = trpl::channel();

    thread::spawn(move || {
        for i in 1..11 {
            tx.send(i).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    trpl::run(async {
        while let Some(message) = rx.recv().await {
            println!("{message}");
        }
    });
}
Listing 17-42: Отправка сообщений с блокирующим кодом в потоке и ожидание сообщений в асинхронном блоке

Мы начинаем с создания асинхронного канала, затем порождаем поток, который принимает владение стороной отправителя канала. Внутри потока мы отправляем числа от 1 до 10, засыпая на секунду между каждым. Наконец, мы запускаем future, созданный с помощью асинхронного блока, переданного в trpl::run, как мы делали на протяжении всей главы. В этом future мы ожидаем эти сообщения, как в других примерах передачи сообщений, которые мы видели.

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

Краткое содержание

Это не последний раз, когда вы увидите конкурентность в этой книге. Проект в Главе 21 применит эти концепции в более реалистичной ситуации, чем более простые примеры, рассмотренные здесь, и более прямо сравнит решение проблем с использованием потоков и задач.

Независимо от того, какой из этих подходов вы выберете, Rust даёт вам инструменты, необходимые для написания безопасного, быстрого, конкурентного кода — будь то высокопроизводительный веб-сервер или встроенная операционная система.

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

Объектно-ориентированные возможности

Объектно-ориентированное программирование (ООП) — это способ моделирования программ. Объекты как программная концепция были введены в язык программирования Simula в 1960-х годах. Эти объекты повлияли на архитектуру программирования Алана Кея, в которой объекты передают друг другу сообщения. Для описания этой архитектуры в 1967 году он ввёл термин объектно-ориентированное программирование. Существуют различные определения того, что такое ООП, и в соответствии с некоторыми из них Rust можно считать объектно-ориентированным, а в соответствии с другими — нет. В этой главе мы рассмотрим определённые характеристики, которые обычно считаются объектно-ориентированными, и то, как эти характеристики проявляются в идиоматичном Rust. Затем мы покажем, как реализовать объектно-ориентированный шаблон проектирования в Rust, и обсудим компромиссы такого подхода по сравнению с решением, использующим сильные стороны Rust.

Характеристики объектно-ориентированных языков

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

Объекты содержат данные и поведение

Книга «Приёмы объектно-ориентированного проектирования. Паттерны проектирования» (Design Patterns: Elements of Reusable Object-Oriented Software) Эриха Гаммы, Ричарда Хелма, Ральфа Джонсона и Джона Влиссидеса (Addison-Wesley, 1994), известная как «Банда четырёх», является каталогом паттернов объектно-ориентированного проектирования. Она определяет ООП следующим образом:

Объектно-ориентированные программы состоят из объектов. Объект объединяет как данные, так и процедуры, работающие с этими данными. Процедуры обычно называются методами или операциями.

Используя это определение, Rust является объектно-ориентированным: структуры и перечисления имеют данные, а блоки impl предоставляют методы для структур и перечислений. Хотя структуры и перечисления с методами не называются объектами, они обеспечивают ту же функциональность согласно определению объектов от «Банды четырёх».

Инкапсуляция, скрывающая детали реализации

Другим аспектом, обычно ассоциируемым с ООП, является идея инкапсуляции, которая означает, что детали реализации объекта недоступны для кода, использующего этот объект. Следовательно, единственный способ взаимодействия с объектом — через его публичный API; код, использующий объект, не должен иметь возможности заглядывать внутрь объекта и напрямую изменять данные или поведение. Это позволяет программисту изменять и рефакторить внутренности объекта без необходимости менять код, который его использует.

Мы обсуждали, как управлять инкапсуляцией в Главе 7: мы можем использовать ключевое слово pub, чтобы решить, какие модули, типы, функции и методы в нашем коде должны быть публичными, а по умолчанию всё остальное является приватным. Например, мы можем определить структуру AveragedCollection, которая имеет поле, содержащее вектор значений i32. Структура также может иметь поле, содержащее среднее значение элементов вектора, что означает, что среднее не нужно вычислять по требованию каждый раз, когда оно кому-то нужно. Другими словами, AveragedCollection будет кэшировать вычисленное среднее для нас. Листинг 18-1 содержит определение структуры AveragedCollection:

Filename: src/lib.rs
pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}
Listing 18-1: Структура AveragedCollection, которая поддерживает список целых чисел и среднее значение элементов в коллекции

Структура помечена как pub, чтобы другой код мог её использовать, но поля внутри структуры остаются приватными. Это важно в данном случае, потому что мы хотим гарантировать, что при добавлении или удалении значения из списка среднее также обновляется. Мы делаем это, реализуя методы add, remove и average для структуры, как показано в Листинге 18-2:

Filename: src/lib.rs
pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

impl AveragedCollection {
    pub fn add(&mut self, value: i32) {
        self.list.push(value);
        self.update_average();
    }

    pub fn remove(&mut self) -> Option<i32> {
        let result = self.list.pop();
        match result {
            Some(value) => {
                self.update_average();
                Some(value)
            }
            None => None,
        }
    }

    pub fn average(&self) -> f64 {
        self.average
    }

    fn update_average(&mut self) {
        let total: i32 = self.list.iter().sum();
        self.average = total as f64 / self.list.len() as f64;
    }
}
Listing 18-2: Реализации публичных методов add, remove и average для AveragedCollection

Публичные методы add, remove и average — это единственные способы доступа или изменения данных в экземпляре AveragedCollection. Когда элемент добавляется в list с помощью метода add или удаляется с помощью метода remove, реализации каждого из них вызывают приватный метод update_average, который обрабатывает обновление поля average.

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

Поскольку мы инкапсулировали детали реализации структуры AveragedCollection, мы можем легко изменять аспекты, такие как структура данных, в будущем. Например, мы могли бы использовать HashSet<i32> вместо Vec<i32> для поля list. До тех пор, пока сигнатуры публичных методов add, remove и average остаются прежними, код, использующий AveragedCollection, не потребует изменений. Если бы мы сделали list публичным вместо этого, это не обязательно было бы так: HashSet<i32> и Vec<i32> имеют разные методы для добавления и удаления элементов, поэтому внешнему коду, вероятно, пришлось бы измениться, если бы он напрямую изменял list.

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

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

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

Если язык должен иметь наследование, чтобы быть объектно-ориентированным, то Rust не является таким языком. Нет способа определить структуру, которая наследует поля и реализации методов родительской структуры без использования макроса.

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

Вы бы выбрали наследование по двум основным причинам. Одна — для повторного использования кода: вы можете реализовать определённое поведение для одного типа, и наследование позволяет повторно использовать эту реализацию для другого типа. Вы можете сделать это ограниченным образом в коде Rust, используя реализации методов типажей по умолчанию, которые вы видели в Листинге 10-14, когда мы добавили реализацию по умолчанию метода summarize в типаже Summary. Любой тип, реализующий типаж Summary, будет иметь доступ к методу summarize без дополнительного кода. Это похоже на то, как родительский класс имеет реализацию метода, а наследующий дочерний класс также имеет реализацию метода. Мы также можем переопределить реализацию по умолчанию метода summarize при реализации типажа Summary, что похоже на переопределение дочерним классом реализации метода, унаследованного от родительского класса.

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

Полиморфизм

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

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

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

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

Использование объектов типажа, допускающих значения разных типов

В главе 8 мы упоминали, что одним из ограничений векторов является возможность хранения элементов только одного типа. Мы создали обходной путь в Листинге 8-9, где определили перечисление SpreadsheetCell с вариантами для хранения целых чисел, чисел с плавающей точкой и текста. Это позволило хранить в каждой ячейке данные разных типов и при этом иметь вектор, представляющий строку ячеек. Это отличное решение, когда наши взаимозаменяемые элементы — это фиксированный набор типов, известный на этапе компиляции.

Однако иногда мы хотим, чтобы пользователь нашей библиотеки мог расширять набор типов, допустимых в конкретной ситуации. Чтобы показать, как этого добиться, мы создадим пример инструмента графического интерфейса (GUI), который перебирает список элементов, вызывая метод draw у каждого для отрисовки на экране — распространённая техника для GUI-инструментов. Мы создадим библиотечный крейт gui, содержащий структуру GUI-библиотеки. Этот крейт может включать некоторые типы для использования, такие как Button или TextField. Кроме того, пользователи gui захотят создавать свои собственные типы, которые можно отрисовывать: например, один программист может добавить Image, а другой — SelectBox.

Мы не будем реализовывать полноценную GUI-библиотеку для этого примера, но покажем, как части будут сочетаться. На момент написания библиотеки мы не можем знать и определять все типы, которые другие программисты могут захотеть создать. Но мы знаем, что gui нужно отслеживать множество значений разных типов и вызывать метод draw для каждого из этих значений разного типа. Ей не нужно знать точно, что произойдёт при вызове draw, только то, что у значения будет доступен этот метод для вызова.

Чтобы сделать это в языке с наследованием, мы могли бы определить класс с именем Component, имеющий метод draw. Другие классы, такие как Button, Image и SelectBox, наследовали бы от Component и, следовательно, наследовали бы метод draw. Они могли бы каждый переопределять метод draw для определения своего пользовательского поведения, но фреймворк мог бы обращаться со всеми типами как с экземплярами Component и вызывать у них draw. Но поскольку Rust не имеет наследования, нам нужен другой способ структурировать библиотеку gui, чтобы позволить пользователям расширять её новыми типами.

Определение типажа для общего поведения

Чтобы реализовать желаемое поведение для gui, мы определим типаж с именем Draw, который будет иметь один метод draw. Затем мы можем определить вектор, принимающий объект типажа. Объект типажа указывает как на экземпляр типа, реализующего наш указанный типаж, так и на таблицу для поиска методов типажа в этом типе во время выполнения. Мы создаём объект типажа, указав некоторый указатель, такой как ссылка & или умный указатель Box<T>, затем ключевое слово dyn и затем указав соответствующий типаж. (Мы поговорим о причине, по которой объекты типажа должны использовать указатель, в разделе «Динамически типизированные типы и типаж Sized» в главе 20.) Мы можем использовать объекты типажа вместо обобщённого или конкретного типа. Везде, где мы используем объект типажа, система типов Rust гарантирует на этапе компиляции, что любое значение, используемое в этом контексте, будет реализовывать типаж объекта типажа. Следовательно, нам не нужно знать все возможные типы на этапе компиляции.

Мы упоминали, что в Rust мы воздерживаемся от названия структур и перечислений «объектами», чтобы отличать их от объектов в других языках. В структуре или перечислении данные в полях структуры и поведение в блоках impl разделены, тогда как в других языках данные и поведение, объединённые в одну концепцию, часто называются объектом. Однако объекты типажа действительно больше похожи на объекты в других языках в том смысле, что они объединяют данные и поведение. Но объекты типажа отличаются от традиционных объектов тем, что мы не можем добавлять данные в объект типажа. Объекты типажа не так универсальны, как объекты в других языках: их конкретная цель — разрешить абстракцию через общее поведение.

Листинг 18-3 показывает, как определить типаж Draw с одним методом draw.

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}
Listing 18-3: Определение типажа Draw

Этот синтаксис должен быть знаком из наших обсуждений по определению типажей в главе 10. Далее идёт новый синтаксис: Листинг 18-4 определяет структуру Screen, которая содержит вектор components. Этот вектор имеет тип Box<dyn Draw>, что является объектом типажа; это заместитель для любого типа внутри Box, реализующего типаж Draw.

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}
Listing 18-4: Определение структуры Screen с полем components, содержащим вектор объектов типажа, реализующих типаж Draw

В структуре Screen мы определим метод run, который будет вызывать метод draw для каждого из своих components, как показано в Листинге 18-5.

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}
Listing 18-5: Метод run в Screen, который вызывает метод draw для каждого компонента

Это работает иначе, чем определение структуры, использующей параметр обобщённого типа с ограничениями типажа. Параметр обобщённого типа может быть заменён только одним конкретным типом за раз, тогда как объекты типажа допускают несколько конкретных типов для заполнения объекта типажа во время выполнения. Например, мы могли бы определить структуру Screen, используя обобщённый тип и ограничение типажа, как в Листинге 18-6:

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

pub struct Screen<T: Draw> {
    pub components: Vec<T>,
}

impl<T> Screen<T>
where
    T: Draw,
{
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}
Listing 18-6: Альтернативная реализация структуры Screen и её метода run с использованием обобщений и ограничений типажа

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

С другой стороны, с методом, использующим объекты типажа, один экземпляр Screen может содержать Vec<T>, который включает Box<Button> и Box<TextField>. Давайте посмотрим, как это работает, а затем обсудим последствия для производительности во время выполнения.

Реализация типажа

Теперь добавим некоторые типы, реализующие типаж Draw. Мы предоставим тип Button. Опять же, фактическая реализация полноценной GUI-библиотеки выходит за рамки этой книги, поэтому метод draw не будет иметь полезной реализации в своём теле. Чтобы представить, как могла бы выглядеть реализация, структура Button может иметь поля width, height и label, как показано в Листинге 18-7:

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // code to actually draw a button
    }
}
Listing 18-7: Структура Button, реализующая типаж Draw

Поля width, height и label в Button будут отличаться от полей в других компонентах; например, тип TextField может иметь те же самые поля плюс поле placeholder. Каждый из типов, которые мы хотим отрисовывать на экране, будет реализовывать типаж Draw, но будет использовать разный код в методе draw для определения, как отрисовывать этот конкретный тип, как это сделал Button (без фактического GUI-кода, как упоминалось). Тип Button, например, может иметь дополнительный блок impl, содержащий методы, связанные с тем, что происходит, когда пользователь нажимает кнопку. Такие методы не будут применяться к типам вроде TextField.

Если кто-то, использующий нашу библиотеку, решит реализовать структуру SelectBox с полями width, height и options, он также реализует типаж Draw для типа SelectBox, как показано в Листинге 18-8.

Filename: src/main.rs
use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

fn main() {}
Listing 18-8: Другой крейт, использующий gui и реализующий типаж Draw для структуры SelectBox

Использование типажа

Теперь пользователь нашей библиотеки может написать свою функцию main для создания экземпляра Screen. В экземпляр Screen он может добавить SelectBox и Button, поместив каждый в Box<T> для превращения в объект типажа. Затем он может вызвать метод run на экземпляре Screen, который вызовет draw для каждого компонента. Листинг 18-9 показывает эту реализацию:

Filename: src/main.rs
use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

use gui::{Button, Screen};

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(SelectBox {
                width: 75,
                height: 10,
                options: vec![
                    String::from("Yes"),
                    String::from("Maybe"),
                    String::from("No"),
                ],
            }),
            Box::new(Button {
                width: 50,
                height: 10,
                label: String::from("OK"),
            }),
        ],
    };

    screen.run();
}
Listing 18-9: Использование объектов типажа для хранения значений разных типов, реализующих один и тот же типаж

Когда мы писали библиотеку, мы не знали, что кто-то может добавить тип SelectBox, но наша реализация Screen смогла работать с новым типом и отрисовывать его, потому что SelectBox реализует типаж Draw, что означает, что он реализует метод draw.

Эта концепция — быть сосредоточенным только на сообщениях, на которые отвечает значение, а не на конкретном типе значения — похожа на концепцию утиной типизации в динамически типизированных языках: если оно ходит как утка и крякает как утка, то оно должно быть уткой! В реализации run для Screen в Листинге 18-5 run не нужно знать конкретный тип каждого компонента. Она не проверяет, является ли компонент экземпляром Button или SelectBox, она просто вызывает метод draw для компонента. Указав Box<dyn Draw> как тип значений в векторе components, мы определили, что Screen нуждаются в значениях, для которых мы можем вызвать метод draw.

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

Например, Листинг 18-10 показывает, что произойдёт, если мы попытаемся создать Screen с String в качестве компонента.

Filename: src/main.rs
use gui::Screen;

fn main() {
    let screen = Screen {
        components: vec![Box::new(String::from("Hi"))],
    };

    screen.run();
}
Listing 18-10: Попытка использовать тип, который не реализует типаж объекта типажа

Мы получим эту ошибку, потому что String не реализует типаж Draw:

$ cargo run
   Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
 --> src/main.rs:5:26
  |
5 |         components: vec![Box::new(String::from("Hi"))],
  |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
  |
  = help: the trait `Draw` is implemented for `Button`
  = note: required for the cast from `Box<String>` to `Box<dyn Draw>`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 previous error

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

Объекты типажа и вывод типов

Один из недостатков использования объектов типажа — как они взаимодействуют с выводом типов. Например, рассмотрим вывод типов для Vec<T>. Когда T не является объектом типажа, Rustу просто нужно знать тип одного элемента в векторе, чтобы вывести T. Поэтому пустой вектор вызывает ошибку вывода типов:

fn main() {
let v = vec![];
// error[E0282]: type annotations needed for `Vec<T>`
}

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

fn main() {
let v = vec!["Hello world"];
// ok, v : Vec<&str>
}

Вывод типов сложнее для объектов типажа. Например, скажем, мы попытались вынести массив components в Листинге 17-9 в отдельную переменную, вот так:

fn main() {
    let components = vec![
        Box::new(SelectBox { /* .. */ }),
        Box::new(Button { /* .. */ }),
    ];
    let screen = Screen { components };
    screen.run();
}

Листинг 17-11: Вынос массива components вызывает ошибку типа

Этот рефакторинг заставляет программу больше не компилироваться! Компилятор отвергает эту программу со следующей ошибкой:

error[E0308]: mismatched types
   --> test.rs:55:14
    |
55  |       Box::new(Button {
    |  _____--------_^
    | |     |
    | |     arguments to this function are incorrect
56  | |       width: 50,
57  | |       height: 10,
58  | |       label: String::from("OK"),
59  | |     }),
    | |_____^ expected `SelectBox`, found `Button`

В Листинге 17-09 компилятор понимает, что вектор components должен иметь тип Vec<Box<dyn Draw>>, потому что это указано в определении структуры Screen. Но в Листинге 17-11 компилятор теряет эту информацию в точке, где определяется components. Чтобы исправить проблему, вы должны дать подсказку алгоритму вывода типов. Это может быть либо через явное приведение любого элемента вектора, вот так:

  let components = vec![
        Box::new(SelectBox { /* .. */ }) as Box<dyn Draw>,
        Box::new(Button { /* .. */ }),
  ];

Либо через аннотацию типа для привязки let, вот так:

  let components: Vec<Box<dyn Draw>> = vec![
        Box::new(SelectBox { /* .. */ }),
        Box::new(Button { /* .. */ }),
  ];

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

Объекты типажа выполняют динамическое диспетчеризацию

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

Когда мы используем объекты типажа, Rust должен использовать динамическую диспетчеризацию. Компилятор не знает все типы, которые могут использоваться с кодом, использующим объекты типажа, поэтому он не знает, какой метод, реализованный на каком типе, вызывать. Вместо этого во время выполнения Rust использует указатели внутри объекта типажа, чтобы знать, какой метод вызвать. Этот поиск несёт затраты во время выполнения, которых нет со статической диспетчеризацией. Динамическая диспетчеризация также мешает компилятору выбирать встраивание кода метода, что, в свою очередь, мешает некоторым оптимизациям, и у Rust есть правила, называемые совместимостью dyn, о том, где можно и нельзя использовать динамическую диспетчеризацию. Эти правила выходят за рамки этого обсуждения, но вы можете прочитать о них больше в справочнике. Однако мы получили дополнительную гибкость в коде, который мы написали в Листинге 18-5, и смогли поддержать в Листинге 18-9, так что это компромисс, который стоит учитывать.

Реализация объектно-ориентированного шаблона проектирования

Шаблон состояний — это объектно-ориентированный шаблон проектирования. Суть шаблона в том, что мы определяем набор состояний, которые значение может иметь внутренне. Состояния представлены набором объектов состояний, и поведение значения меняется в зависимости от его состояния. Мы разберём пример структуры записи блога, которая имеет поле для хранения своего состояния, которое будет объектом состояния из набора «черновик», «на рассмотрении» или «опубликовано».

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

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

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

Итоговая функциональность будет выглядеть так:

  1. Запись блога начинается как пустой черновик.
  2. Когда черновик готов, запись отправляется на рассмотрение.
  3. Когда запись одобрена, она публикуется.
  4. Только опубликованные записи блога возвращают содержимое для печати, поэтому неодобренные записи не могут быть случайно опубликованы.

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

Листинг 18-11 показывает этот рабочий процесс в коде: это пример использования API, который мы реализуем в библиотечном крейте blog. Это пока не скомпилируется, потому что мы ещё не реализовали крейт blog.

Filename: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}
Listing 18-11: Код, демонстрирующий желаемое поведение, которое мы хотим, чтобы наш крейт blog имел

Мы хотим разрешить пользователю создавать новый черновик записи блога с помощью Post::new. Мы хотим разрешить добавлять текст в запись блога. Если мы пытаемся получить содержимое записи сразу, до одобрения, мы не должны получать текст, потому что запись всё ещё черновик. Мы добавили assert_eq! в код для демонстрационных целей. Отличным модульным тестом было бы утверждение, что черновик записи блога возвращает пустую строку из метода content, но мы не будем писать тесты для этого примера.

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

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

Определение Post и создание нового экземпляра в состоянии черновика

Давайте начнём реализацию библиотеки! Мы знаем, что нам нужен публичный структура Post, которая содержит некоторое содержимое, поэтому начнём с определения структуры и связанной публичной функции new для создания экземпляра Post, как показано в Листинге 18-12. Мы также создадим приватный типаж State, который определит поведение, которое все объекты состояний для Post должны иметь.

Затем Post будет содержать объект типажа Box<dyn State> внутри Option<T> в приватном поле с именем state для хранения объекта состояния. Вы увидите, почему Option<T> необходим, через мгновение.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
Listing 18-12: Определение структуры Post и функции new, которая создаёт новый экземпляр Post, типажа State и структуры Draft

Типаж State определяет поведение, разделяемое различными состояниями записи. Объекты состояний — это Draft, PendingReview и Published, и все они будут реализовывать типаж State. Пока типаж не имеет никаких методов, и мы начнём с определения только состояния Draft, потому что это состояние, в котором мы хотим, чтобы запись начинала.

Когда мы создаём новый Post, мы устанавливаем его поле state в значение Some, которое содержит Box. Этот Box указывает на новый экземпляр структуры Draft. Это гарантирует, что всякий раз, когда мы создаём новый экземпляр Post, он начнёт как черновик. Поскольку поле state типажа Post приватно, нет способа создать Post в любом другом состоянии! В функции Post::new мы устанавливаем поле content в новую пустую String.

Хранение текста содержимого записи

Мы видели в Листинге 18-11, что мы хотим иметь возможность вызывать метод с именем add_text и передавать ему &str, который затем добавляется как текстовое содержимое записи блога. Мы реализуем это как метод, а не раскрываем поле content как pub, чтобы позже мы могли реализовать метод, который будет контролировать, как читаются данные поля content. Метод add_text довольно прост, поэтому давайте добавим реализацию в Листинге 18-13 в блок impl Post.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
Listing 18-13: Реализация метода add_text для добавления текста в content записи

Метод add_text принимает изменяемую ссылку на self, потому что мы изменяем экземпляр Post, на котором вызываем add_text. Затем мы вызываем push_str на String в content и передаём аргумент text для добавления в сохранённое content. Это поведение не зависит от состояния записи, поэтому оно не является частью шаблона состояний. Метод add_text вообще не взаимодействует с полем state, но он является частью поведения, которое мы хотим поддерживать.

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

Даже после того, как мы вызвали add_text и добавили некоторое содержимое в нашу запись, мы всё ещё хотим, чтобы метод content возвращал пустой срез строки, потому что запись всё ещё в состоянии черновика, как показано на строке 7 Листинга 18-11. Пока давайте реализуем метод content с самой простой вещью, которая выполнит это требование: всегда возвращать пустой срез строки. Мы изменим это позже, как только реализуем возможность изменения состояния записи, чтобы её можно было опубликовать. Пока записи могут быть только в состоянии черновика, поэтому содержимое записи всегда должно быть пустым. Листинг 18-14 показывает эту заглушку.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
Listing 18-14: Добавление заглушки реализации метода content на Post, который всегда возвращает пустой срез строки

С этим добавленным методом content всё в Листинге 18-11 до строки 7 работает как задумано.

Запрос рассмотрения изменяет состояние записи

Далее, нам нужно добавить функциональность для запроса рассмотрения записи, что должно изменить её состояние с Draft на PendingReview. Листинг 18-15 показывает этот код.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
Listing 18-15: Реализация методов request_review на Post и типаже State

Мы даём Post публичный метод с именем request_review, который будет принимать изменяемую ссылку на self. Затем мы вызываем внутренний метод request_review на текущем состоянии Post, и этот второй метод request_review потребляет текущее состояние и возвращает новое состояние.

Мы добавляем метод request_review в типаж State; все типажи, которые реализуют этот типаж, теперь должны будут реализовать метод request_review. Обратите внимание, что вместо того, чтобы иметь self, &self или &mut self в качестве первого параметра метода, мы имеем self: Box<Self>. Этот синтаксис означает, что метод действителен только при вызове на Box, содержащем типаж. Этот синтаксис принимает владение Box<Self>, делая старое состояние недействительным, чтобы значение состояния Post могло преобразоваться в новое состояние.

Чтобы потребить старое состояние, методу request_review нужно принять владение значением состояния. Здесь вступает в силу Option в поле state типажа Post: мы вызываем метод take, чтобы взять значение Some из поля state и оставить None на его месте, потому что Rust не позволяет иметь незаполненные поля в структурах. Это позволяет нам переместить значение state из Post, а не занимать его заимствованием. Затем мы установим значение state записи на результат этой операции.

Нам нужно установить state в None временно, а не устанавливать его напрямую кодом вроде self.state = self.state.request_review();, чтобы получить владение значением state. Это гарантирует, что Post не сможет использовать старое значение state после того, как мы преобразовали его в новое состояние.

Метод request_review на Draft возвращает новый, упакованный в Box экземпляр новой структуры PendingReview, которая представляет состояние, когда запись ожидает рассмотрения. Структура PendingReview также реализует метод request_review, но не выполняет никаких преобразований. Скорее, она возвращает себя, потому что когда мы запрашиваем рассмотрение записи, уже находящейся в состоянии PendingReview, она должна остаться в состоянии PendingReview.

Теперь мы начинаем видеть преимущества шаблона состояний: метод request_review на Post один и тот же независимо от его значения state. Каждое состояние отвечает за свои собственные правила.

Мы оставим метод content на Post как есть, возвращающий пустой срез строки. Теперь у нас может быть Post в состоянии PendingReview, а также в состоянии Draft, но мы хотим того же поведения в состоянии PendingReview. Листинг 18-11 теперь работает до строки 10!

Добавление approve для изменения поведения content

Метод approve будет похож на метод request_review: он установит state в значение, которое текущее состояние говорит, что оно должно иметь, когда это состояние одобрено, как показано в Листинге 18-16:

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
Listing 18-16: Реализация метода approve на Post и типаже State

Мы добавляем метод approve в типаж State и добавляем новую структуру, которая реализует State, состояние Published.

Подобно тому, как работает request_review на PendingReview, если мы вызываем метод approve на Draft, это не будет иметь эффекта, потому что approve вернёт self. Когда мы вызываем approve на PendingReview, он возвращает новый, упакованный в Box экземпляр структуры Published. Структура Published реализует типаж State, и для обоих методов request_review и approve она возвращает себя, потому что запись должна остаться в состоянии Published в этих случаях.

Теперь нам нужно обновить метод content на Post. Мы хотим, чтобы значение, возвращаемое из content, зависело от текущего состояния Post, поэтому мы будем делегировать Post методу content, определённому на его state, как показано в Листинге 18-17:

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }
    // --snip--

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
Listing 18-17: Обновление метода content на Post для делегирования методу content на State

Поскольку цель — сохранить все эти правила внутри структур, которые реализуют State, мы вызываем метод content на значении в state и передаём экземпляр записи (то есть self) в качестве аргумента. Затем мы возвращаем значение, которое возвращается из использования метода content на значении state.

Мы вызываем метод as_ref на Option, потому что мы хотим ссылку на значение внутри Option, а не владение значением. Поскольку state является Option<Box<dyn State>>, когда мы вызываем as_ref, возвращается Option<&Box<dyn State>>. Если бы мы не вызвали as_ref, мы получили бы ошибку, потому что не можем переместить state из заимствованного &self параметра функции.

Затем мы вызываем метод unwrap, который, как мы знаем, никогда не вызовет панику, потому что мы знаем, что методы на Post обеспечивают, что state всегда будет содержать значение Some, когда эти методы завершаются. Это один из случаев, о которых мы говорили в разделе «Случаи, в которых у вас больше информации, чем у компилятора» в Главе 9, когда мы знаем, что значение None никогда не возможно, даже если компилятор не способен это понять.

На этом этапе, когда мы вызываем content на &Box<dyn State>, вступает в силу разыменование (deref coercion) для & и Box, так что метод content в конечном итоге будет вызван на типаже, который реализует типаж State. Это означает, что нам нужно добавить content в определение типажа State, и там мы поместим логику того, какое содержимое возвращать в зависимости от того, какое состояние у нас есть, как показано в Листинге 18-18:

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;

    fn content<'a>(&self, post: &'a Post) -> &'a str {
        ""
    }
}

// --snip--

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn content<'a>(&self, post: &'a Post) -> &'a str {
        &post.content
    }
}
Listing 18-18: Добавление метода content в типаж State

Мы добавляем реализацию по умолчанию для метода content, которая возвращает пустой срез строки. Это означает, что нам не нужно реализовывать content на структурах Draft и PendingReview. Структура Published переопределит метод content и вернёт значение в post.content.

Обратите внимание, что нам нужны аннотации времени жизни на этом методе, как мы обсуждали в Главе 10. Мы принимаем ссылку на post в качестве аргумента и возвращаем ссылку на часть этого post, поэтому время жизни возвращаемой ссылки связано со временем жизни аргумента post.

И мы закончили — весь Листинг 18-11 теперь работает! Мы реализовали шаблон состояний с правилами рабочего процесса записи блога. Логика, связанная с правилами, находится в объектах состояний, а не разбросана по всему Post.

Почему не использовать перечисление?

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

Компромиссы шаблона состояний

Мы показали, что Rust способен реализовать объектно-ориентированный шаблон состояний для инкапсуляции различных видов поведения, которые запись должна иметь в каждом состоянии. Методы на Post ничего не знают о различном поведении. Способ, которым мы организовали код, мы должны смотреть только в одном месте, чтобы знать различные способы поведения опубликованной записи: реализацию типажа State на структуре Published.

Если бы мы создали альтернативную реализацию, которая не использовала бы шаблон состояний, мы могли бы вместо этого использовать выражения match в методах на Post или даже в коде main, которые проверяют состояние записи и изменяют поведение в этих местах. Это означало бы, что нам нужно смотреть в нескольких местах, чтобы понять все последствия того, что запись находится в опубликованном состоянии! Это только увеличилось бы, если бы мы добавляли больше состояний: каждое из этих выражений match потребовало бы ещё одного рукава.

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

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

  • Добавьте метод reject, который изменяет состояние записи с PendingReview обратно на Draft.
  • Требуйте два вызова approve перед тем, как состояние может быть изменено на Published.
  • Разрешите пользователям добавлять текстовое содержимое только когда запись находится в состоянии Draft. Подсказка: пусть объект состояния отвечает за то, что может измениться в содержимом, но не отвечает за изменение Post.

Один недостаток шаблона состояний в том, что, поскольку состояния реализуют переходы между состояниями, некоторые состояния связаны друг с другом. Если мы добавим другое состояние между PendingReview и Published, например Scheduled, нам придётся изменить код в PendingReview, чтобы перейти к Scheduled вместо. Это было бы меньше работы, если бы PendingReview не нужно было изменять с добавлением нового состояния, но это означало бы переход к другому шаблону проектирования.

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

Другое дублирование включает схожие реализации методов request_review и approve на Post. Оба метода используют Option::take с полем state типажа Post, и если state является Some, они делегируют реализации обёрнутого значения того же метода и устанавливают новое значение поля state на результат. Если бы у нас было много методов на Post, которые следовали этому шаблону, мы могли бы рассмотреть определение макроса для устранения повторения (см. раздел «Макросы» в Главе 20).

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

Кодирование состояний и поведения как типов

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

Давайте рассмотрим первую часть main в Листинге 18-11:

Filename: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}

Мы всё ещё разрешаем создание новых записей в состоянии черновика с помощью Post::new и возможность добавлять текст в содержимое записи. Но вместо того, чтобы иметь метод content на черновике записи, который возвращает пустую строку, мы сделаем так, чтобы у черновиков записей не было метода content вообще. Таким образом, если мы попытаемся получить содержимое черновика записи, мы получим ошибку компилятора, сообщающую, что метод не существует. В результате будет невозможно случайно отобразить содержимое черновика записи в продакшене, потому что этот код даже не скомпилируется. Листинг 18-19 показывает определение структуры Post и структуры DraftPost, а также методы для каждого.

Filename: src/lib.rs
pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

    pub fn content(&self) -> &str {
        &self.content
    }
}

impl DraftPost {
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}
Listing 18-19: Post с методом content и DraftPost без метода content

И структура Post, и структура DraftPost имеют приватное поле content, которое хранит текст записи блога. Структуры больше не имеют поля state, потому что мы перемещаем кодирование состояния в типажи структур. Структура Post будет представлять опубликованную запись, и у неё есть метод content, который возвращает content.

У нас всё ещё есть функция Post::new, но вместо возврата экземпляра Post она возвращает экземпляр DraftPost. Поскольку content приватно и нет функций, которые возвращают Post, сейчас невозможно создать экземпляр Post.

Структура DraftPost имеет метод add_text, поэтому мы можем добавлять текст в content как раньше, но обратите внимание, что на DraftPost не определён метод content! Так что теперь программа обеспечивает, что все записи начинаются как черновики, и у черновиков записей их содержимое недоступно для отображения. Любая попытка обойти эти ограничения приведёт к ошибке компилятора.

Реализация переходов как преобразований в различные типажи

Так как же мы получаем опубликованную запись? Мы хотим обеспечить правило, что черновик записи должен быть рассмотрен и одобрен, прежде чем его можно будет опубликовать. Запись в состоянии ожидания рассмотрения всё ещё не должна отображать какое-либо содержимое. Давайте реализуем эти ограничения, добавив ещё одну структуру, PendingReviewPost, определив метод request_review на DraftPost для возврата PendingReviewPost и определив метод approve на PendingReviewPost для возврата Post, как показано в Листинге 18-20.

Filename: src/lib.rs
pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

    pub fn content(&self) -> &str {
        &self.content
    }
}

impl DraftPost {
    // --snip--
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn request_review(self) -> PendingReviewPost {
        PendingReviewPost {
            content: self.content,
        }
    }
}

pub struct PendingReviewPost {
    content: String,
}

impl PendingReviewPost {
    pub fn approve(self) -> Post {
        Post {
            content: self.content,
        }
    }
}
Listing 18-20: PendingReviewPost, который создаётся вызовом request_review на DraftPost, и метод approve, который превращает PendingReviewPost в опубликованный Post

Методы request_review и approve принимают владение self, таким образом потребляя экземпляры DraftPost и PendingReviewPost и преобразуя их в PendingReviewPost и опубликованный Post соответственно. Таким образом, у нас не останется никаких экземпляров DraftPost после того, как мы вызовем request_review на них, и так далее. Структура PendingReviewPost не имеет метода content, определённого на ней, поэтому попытка прочитать её содержимое приводит к ошибке компилятора, как и с DraftPost. Поскольку единственный способ получить опубликованный экземпляр Post, который имеет определённый метод content, — это вызвать метод approve на PendingReviewPost, и единственный способ получить PendingReviewPost — вызвать метод request_review на DraftPost, мы теперь закодировали рабочий процесс записи блога в систему типов.

Но нам также нужно внести небольшие изменения в main. Методы request_review и approve возвращают новые экземпляры, а не изменяют структуру, на которой они вызваны, поэтому нам нужно добавить больше присваиваний let post = с затенением, чтобы сохранить возвращённые экземпляры. Мы также не можем иметь утверждения о том, что содержимости черновиков и записей на рассмотрении являются пустыми строками, и нам это и не нужно: мы больше не можем скомпилировать код, который пытается использовать содержимое записей в этих состояниях. Обновлённый код в main показан в Листинге 18-21.

Filename: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");

    let post = post.request_review();

    let post = post.approve();

    assert_eq!("I ate a salad for lunch today", post.content());
}
Listing 18-21: Изменения в main для использования новой реализации рабочего процесса записи блога

Изменения, которые нам нужно было внести в main для повторного присваивания post, означают, что эта реализация больше не совсем следует объектно-ориентированному шаблону состояний: преобразования между состояниями больше не инкапсулированы полностью внутри реализации Post. Однако наша выгода в том, что недействительные состояния теперь невозможны из-за системы типов и проверки типов, происходящей на этапе компиляции! Это гарантирует, что определённые ошибки, такие как отображение содержимого неопубликованной записи, будут обнаружены до того, как они попадут в продакшен.

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

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

Резюме

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

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

Инвентаризация владения #4

«Инвентаризация владения» — это серия викторин, которые проверяют ваше понимание владения в реальных сценариях. Эти сценарии вдохновлены распространёнными вопросами о Rust на StackOverflow.

Компромиссы проектирования

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

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

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

Функциональность: Приложению необходимо передавать неизменяемые ссылки на эту конфигурацию по всему приложению.

Проектные варианты: Ниже представлены несколько предложенных вариантов реализации функциональности.

use std::rc::Rc;
use std::sync::Arc;

struct Config { 
    flags: Flags,
    // .. more fields ..
}

// Вариант 1: использовать ссылку
struct ConfigRef<'a>(&'a Config);

// Вариант 2: использовать указатель с подсчётом ссылок
struct ConfigRef(Rc<Config>);

// Вариант 3: использовать атомарный указатель с подсчётом ссылок
struct ConfigRef(Arc<Config>);

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

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

Требование: Ссылка на конфигурацию должна быть общей (shareable) между несколькими потоками.

Ответ:

Вариант 1
Вариант 2
Вариант 3

В формальных терминах это означает, что ConfigRef реализует Send и Sync. Предполагая, что Config: Send + Sync, тогда и &Config, и Arc<Config> удовлетворяют этому требованию, но Rc — нет (потому что неатомарные указатели с подсчётом ссылок не являются потокобезопасными). Таким образом, Вариант 2 не удовлетворяет требованию, а Вариант 3 — удовлетворяет.

Мы также можем быть склонны заключить, что Вариант 1 не удовлетворяет требованию, потому что такие функции, как thread::spawn, требуют, чтобы все данные, перемещаемые в поток, содержали только ссылки с временем жизни 'static. Однако это не исключает Вариант 1 по двум причинам:

  1. Config может храниться как глобальная статическая переменная (например, с использованием OnceLock), поэтому можно создать ссылки &'static Config.
  2. Не все механизмы конкурентности требуют времен жизни 'static, например, thread::scope.

Таким образом, данное требование, как сформулировано, исключает только типы, не реализующие Send, и мы считаем, что Варианты 1 и 3 являются правильными ответами.


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

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

Ссылки

Вдохновение: Активы Bevy, Индексы узлов Petgraph, Единицы Cargo

Деревья типов

Вдохновение: Компоненты Yew, Виджеты Druid

Диспетчеризация

Вдохновение: Системы Bevy, Запросы Diesel, Обработчики Axum

Промежуточные представления

Вдохновение: Serde и miniserde

Образцы и сопоставление

Образцы (patterns) — это особый синтаксис в Rust для сопоставления со структурой типов, как сложных, так и простых. Использование образцов вместе с выражениями match и другими конструкциями даёт больше контроля над потоком выполнения программы. Образец состоит из комбинации следующих элементов:

  • Литералов
  • Разобранных (destructured) массивов, перечислений, структур или кортежей
  • Переменных
  • Шаблонов wildcard (любое значение)
  • Заполнителей (placeholders)

Примеры образцов: x, (a, 3), Some(Color::Red). В контекстах, где образцы допустимы, эти компоненты описывают форму данных. Затем программа сопоставляет значения с образцами, чтобы определить, соответствуют ли данные требуемой форме для продолжения выполнения конкретного фрагмента кода.

Чтобы использовать образец, мы сравниваем его с некоторым значением. Если образец совпадает со значением, мы используем части значения в своём коде. Вспомните выражения match из Главы 6, которые использовали образцы, например, пример с сортировкой монет. Если значение соответствует форме образца, мы можем использовать именованные части. Если нет — код, связанный с этим образцом, не выполнится.

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

Все места, где можно использовать образцы

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

Ветви match

Как обсуждалось в Главе 6, мы используем образцы в ветвях выражений match. Формально выражение match определяется ключевым словом match, значением для сопоставления и одной или несколькими ветвями match, каждая из которых состоит из образца и выражения, выполняемого при совпадении значения с образцом этой ветви, например:

match ЗНАЧЕНИЕ {
    ОБРАЗЕЦ => ВЫРАЖЕНИЕ,
    ОБРАЗЕЦ => ВЫРАЖЕНИЕ,
    ОБРАЗЕЦ => ВЫРАЖЕНИЕ,
}

Например, вот выражение match из Листинга 6-5, которое сопоставляет значение Option<i32> в переменной x:

match x {
    None => None,
    Some(i) => Some(i + 1),
}

Образцами в этом выражении match являются None и Some(i) слева от каждой стрелки.

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

Конкретный образец _ соответствует чему угодно, но никогда не связывает с переменной, поэтому он часто используется в последней ветви match. Образец _ может быть полезен, когда вы хотите проигнорировать любое значение, не указанное явно, например. Мы подробнее рассмотрим образец _ в разделе «Игнорирование значений в образце» позже в этой главе.

Условные выражения if let

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

Листинг 19-1 показывает, что также возможно смешивать выражения if let, else if и else if let. Это даёт нам больше гибкости, чем выражение match, в котором мы можем выразить только одно значение для сравнения с образцами. Кроме того, Rust не требует, чтобы условия в серии ветвей if let, else if, else if let были связаны друг с другом.

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

Filename: src/main.rs
fn main() {
    let favorite_color: Option<&str> = None;
    let is_tuesday = false;
    let age: Result<u8, _> = "34".parse();

    if let Some(color) = favorite_color {
        println!("Using your favorite color, {color}, as the background");
    } else if is_tuesday {
        println!("Tuesday is green day!");
    } else if let Ok(age) = age {
        if age > 30 {
            println!("Using purple as the background color");
        } else {
            println!("Using orange as the background color");
        }
    } else {
        println!("Using blue as the background color");
    }
}
Listing 19-1: Смешивание if let, else if, else if let и else

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

Эта условная структура позволяет поддерживать сложные требования. С жёстко заданными значениями, которые мы используем здесь, этот пример выведет Using purple as the background color.

Вы можете видеть, что if let также может вводить новые переменные, которые скрывают существующие переменные таким же образом, как это делают ветви match: строка if let Ok(age) = age вводит новую переменную age, содержащую значение внутри варианта Ok, скрывая существующую переменную age. Это означает, что нам нужно разместить условие if age > 30 внутри этого блока: мы не можем объединить эти два условия в if let Ok(age) = age && age > 30. Новая age, которую мы хотим сравнить с 30, недействительна, пока не начнётся новая область видимости с фигурной скобкой.

Недостаток использования выражений if let в том, что компилятор не проверяет их на исчерпываемость, тогда как для выражений match это делает. Если мы опустили бы последний блок else и, следовательно, не обработали бы некоторые случаи, компилятор не предупредил бы нас о возможной логической ошибке.

Условные циклы while let

Построение похоже на if let: условный цикл while let позволяет циклу while выполняться до тех пор, пока образец продолжает соответствовать. В Листинге 19-2 мы показываем цикл while let, который ожидает сообщения, отправляемых между потоками, но в этом случае проверяет Result вместо Option.

fn main() {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        for val in [1, 2, 3] {
            tx.send(val).unwrap();
        }
    });

    while let Ok(value) = rx.recv() {
        println!("{value}");
    }
}
Listing 19-2: Использование цикла while let для печати значений, пока rx.recv() возвращает Ok

Этот пример выводит 1, 2, а затем 3. Метод recv берёт первое сообщение из приёмной стороны канала и возвращает Ok(value). Когда мы впервые увидели recv в Главе 16, мы раскрывали ошибку напрямую или взаимодействовали с ней как с итератором, используя цикл for. Однако, как показывает Листинг 19-2, мы также можем использовать while let, потому что метод recv возвращает Ok каждый раз, когда прибывает сообщение, пока существует отправитель, а затем выдаёт Err, как только сторона отправителя отключается.

Циклы for

В цикле for значение, которое непосредственно следует за ключевым словом for, является образцом. Например, в for x in y x — это образец. Листинг 19-3 демонстрирует, как использовать образец в цикле for для деструктуризации, или разбора, кортежа как части цикла for.

fn main() {
    let v = vec!['a', 'b', 'c'];

    for (index, value) in v.iter().enumerate() {
        println!("{value} is at index {index}");
    }
}
Listing 19-3: Использование образца в цикле for для деструктуризации кортежа

Код в Листинге 19-3 выведет следующее:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.52s
     Running `target/debug/patterns`
a is at index 0
b is at index 1
c is at index 2

Мы адаптируем итератор с помощью метода enumerate, чтобы он производил значение и индекс для этого значения, помещённые в кортеж. Первое произведённое значение — это кортеж (0, 'a'). Когда это значение сопоставляется с образцом (index, value), index будет 0, а value будет 'a', печатая первую строку вывода.

Операторы let

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

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

Каждый раз, когда вы использовали оператор let подобным образом, вы использовали образцы, хотя могли и не осознавать этого! Более формально оператор let выглядит так:

let ОБРАЗЕЦ = ВЫРАЖЕНИЕ;

В операторах, подобных let x = 5;, где в слоте ОБРАЗЕЦ находится имя переменной, имя переменной — это просто особо простая форма образца. Rust сравнивает выражение с образцом и присваивает все имена, которые находит. Поэтому в примере let x = 5; x — это образец, который означает «связать то, что совпадает здесь, с переменной x». Поскольку имя x — это весь образец, этот образец по сути означает «связать всё с переменной x, каким бы ни было значение».

Чтобы яснее увидеть аспект сопоставления образцов в let, рассмотрим Листинг 19-4, который использует образец с let для деструктуризации кортежа.

fn main() {
    let (x, y, z) = (1, 2, 3);
}
Listing 19-4: Использование образца для деструктуризации кортежа и создания трёх переменных одновременно

Здесь мы сравниваем кортеж с образцом. Rust сравнивает значение (1, 2, 3) с образцом (x, y, z) и видит, что значение совпадает с образцом, в том смысле, что количество элементов одинаково в обоих, поэтому Rust связывает 1 с x, 2 с y и 3 с z. Вы можете думать об этом образце кортежа как о вложении трёх отдельных образцов переменных внутри него.

Если количество элементов в образце не совпадает с количеством элементов в кортеже, общий тип не будет совпадать, и мы получим ошибку компиляции. Например, Листинг 19-5 показывает попытку деструктуризации кортежа с тремя элементами в две переменные, что не сработает.

fn main() {
    let (x, y) = (1, 2, 3);
}
Listing 19-5: Некорректное построение образца, переменные которого не соответствуют количеству элементов в кортеже

Попытка скомпилировать этот код приводит к следующей ошибке типа:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error[E0308]: mismatched types
 --> src/main.rs:2:9
  |
2 |     let (x, y) = (1, 2, 3);
  |         ^^^^^^   --------- this expression has type `({integer}, {integer}, {integer})`
  |         |
  |         expected a tuple with 3 elements, found one with 2 elements
  |
  = note: expected tuple `({integer}, {integer}, {integer})`
             found tuple `(_, _)`

For more information about this error, try `rustc --explain E0308`.
error: could not compile `patterns` (bin "patterns") due to 1 previous error

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

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

Параметры функции также могут быть образцами. Код в Листинге 19-6, который объявляет функцию с именем foo, принимающую один параметр с именем x типа i32, должен уже выглядеть знакомо.

fn foo(x: i32) {
    // code goes here
}

fn main() {}
Listing 19-6: Сигнатура функции использует образцы в параметрах

Часть x — это образец! Как мы это делали с let, мы могли бы сопоставить кортеж в аргументах функции с образцом. Листинг 19-7 разделяет значения в кортеже при передаче его функции.

Filename: src/main.rs
fn print_coordinates(&(x, y): &(i32, i32)) {
    println!("Current location: ({x}, {y})");
}

fn main() {
    let point = (3, 5);
    print_coordinates(&point);
}
Listing 19-7: Функция с параметрами, которые деструктурируют кортеж

Этот код выводит Current location: (3, 5). Значения &(3, 5) совпадают с образцом &(x, y), поэтому x — это значение 3, а y — значение 5.

Мы также можем использовать образцы в списках параметров замыкания таким же образом, как и в списках параметров функции, потому что замыкания похожи на функции, как обсуждалось в Главе 13.

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

ОПРОВЕРЖИМОСТЬ: МОЖЕТ ЛИ ОБРАЗЕЦ НЕ СОВПАСТИ

Образцы бывают двух видов: опровержимые и неопровержимые. Образцы, которые совпадают с любым возможным значением, являются неопровержимыми. Примером служит x в выражении let x = 5;, поскольку x совпадает с чем угодно и поэтому не может не совпасть. Образцы, которые могут не совпасть для некоторых значений, являются опровержимыми. Вот несколько примеров:

  • В выражении if let Some(x) = a_value образец Some(x) является опровержимым. Если значение в переменной a_value равно None, а не Some, образец Some(x) не совпадёт.
  • В выражении if let &[x, ..] = a_slice образец &[x, ..] является опровержимым. Если значение в переменной a_slice имеет нулевое количество элементов, образец &[x, ..] не совпадёт.

Параметры функций, операторы let и циклы for могут принимать только неопровержимые образцы, поскольку программа не может сделать ничего осмысленного, если значения не совпадают. Выражения if let и while let, а также оператор let...else принимают и опровержимые, и неопровержимые образцы, но компилятор предупреждает об использовании неопровержимых образцов, потому что они, по определению, предназначены для обработки возможного неудачного совпадения: функциональность условного оператора заключается в его способности выполнять разные действия в зависимости от успеха или неудачи.

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

Рассмотрим пример того, что происходит, когда мы пытаемся использовать опровержимый образец там, где Rust требует неопровержимый, и наоборот. Листинг 19-8 показывает оператор let, но в качестве образца мы указали Some(x) — опровержимый образец. Как можно ожидать, этот код не скомпилируется.

fn main() {
    let some_option_value: Option<i32> = None;
    let Some(x) = some_option_value;
}
Listing 19-8: Попытка использовать опровержимый образец с let

Если бы some_option_value было значением None, оно не совпало бы с образцом Some(x), что означает, что образец опровержим. Однако оператор let может принимать только неопровержимый образец, поскольку нет ничего осмысленного, что код мог бы сделать со значением None. На этапе компиляции Rust сообщит, что мы попытались использовать опровержимый образец там, где требуется неопровержимый:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error[E0005]: refutable pattern in local binding
 --> src/main.rs:3:9
  |
3 |     let Some(x) = some_option_value;
  |         ^^^^^^^ pattern `None` not covered
  |
  = note: `let` bindings require an "irrefutable pattern", like a `struct` or an `enum` with only one variant
  = note: for more information, visit https://doc.rust-lang.org/book/ch19-02-refutability.html
  = note: the matched value is of type `Option<i32>`
help: you might want to use `let else` to handle the variant that isn't matched
  |
3 |     let Some(x) = some_option_value else { todo!() };
  |                                     ++++++++++++++++

For more information about this error, try `rustc --explain E0005`.
error: could not compile `patterns` (bin "patterns") due to 1 previous error

Поскольку мы не покрыли (и не могли покрыть!) каждое допустимое значение образцом Some(x), Rust справедливо выдаёт ошибку компиляции.

Если у нас есть опровержимый образец там, где нужен неопровержимый, мы можем исправить это, изменив код, который использует образец: вместо let мы можем использовать if let. Тогда, если образец не совпадёт, код просто пропустит блок в фигурных скобках, что даст ему возможность продолжить корректно. Листинг 19-9 показывает, как исправить код из листинга 19-8.

fn main() {
    let some_option_value: Option<i32> = None;
    let Some(x) = some_option_value else {
        return;
    };
}
Listing 19-9: Использование let...else и блока с опровержимыми образцами вместо let

Мы дали коду выход! Этот код теперь совершенно корректен. Однако, если мы дадим if let неопровержимый образец (образец, который всегда совпадёт), такой как x, как показано в листинге 19-10, компилятор выдаст предупреждение.

fn main() {
    let x = 5 else {
        return;
    };
}
Listing 19-10: Попытка использовать неопровержимый образец с if let

Rust жалуется, что использование if let с неопровержимым образцом бессмысленно:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
warning: irrefutable `let...else` pattern
 --> src/main.rs:2:5
  |
2 |     let x = 5 else {
  |     ^^^^^^^^^
  |
  = note: this pattern will always match, so the `else` clause is useless
  = help: consider removing the `else` clause
  = note: `#[warn(irrefutable_let_patterns)]` on by default

warning: `patterns` (bin "patterns") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.39s
     Running `target/debug/patterns`

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

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

Синтаксис образцов

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

Сопоставление с литералами

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

fn main() {
    let x = 1;

    match x {
        1 => println!("one"),
        2 => println!("two"),
        3 => println!("three"),
        _ => println!("anything"),
    }
}

Этот код выводит one, потому что значение в x равно 1. Этот синтаксис полезен, когда вы хотите, чтобы ваш код выполнял действие при получении конкретного значения.

Сопоставление с именованными переменными

Именованные переменные — это неопровержимые образцы, которые соответствуют любому значению, и мы многократно использовали их в этой книге. Однако возникает сложность, когда вы используете именованные переменные в выражениях match, if let или while let. Поскольку каждое из этих выражений начинает новую область видимости, переменные, объявленные как часть образца внутри выражения, будут скрывать переменные с тем же именем вне его, как и в случае со всеми переменными. В листинге 19-11 мы объявляем переменную x со значением Some(5) и переменную y со значением 10. Затем создаём выражение match для значения x. Посмотрите на образцы в ветвях match и println! в конце и попробуйте представить, что выведет код, прежде чем запускать его или читать дальше.

Filename: src/main.rs
fn main() {
    let x = Some(5);
    let y = 10;

    match x {
        Some(50) => println!("Got 50"),
        Some(y) => println!("Matched, y = {y}"),
        _ => println!("Default case, x = {x:?}"),
    }

    println!("at the end: x = {x:?}, y = {y}");
}
Listing 19-11: Выражение match с ветвью, которая вводит новую переменную, скрывающую существующую переменную y

Давайте разберём, что происходит при выполнении выражения match. Образец в первой ветви match не соответствует определённому значению x, поэтому код продолжает выполнение.

Образец во второй ветви match вводит новую переменную с именем y, которая будет соответствовать любому значению внутри Some. Поскольку мы находимся в новой области видимости внутри выражения match, это новая переменная y, а не та y, которую мы объявили в начале со значением 10. Эта новая привязка y соответствует любому значению внутри Some, что и есть у нас в x. Следовательно, эта новая y привязывается к внутреннему значению Some в x. Это значение равно 5, поэтому выполняется код для этой ветви и выводится Matched, y = 5.

Если бы x имел значение None вместо Some(5), образцы в первых двух ветвях не соответствовали бы, и значение соответствовало бы подчёркиванию. Мы не вводили переменную x в образце ветви с подчёркиванием, поэтому x в выражении всё ещё является внешним x, который не был скрыт. В этом гипотетическом случае match вывел бы Default case, x = None.

Когда выражение match завершается, его область видимости заканчивается, и заканчивается область видимости внутренней y. Последний println! выводит at the end: x = Some(5), y = 10.

Чтобы создать выражение match, которое сравнивает значения внешних x и y, а не вводит новую переменную, скрывающую существующую переменную y, нам нужно вместо этого использовать условное сторожевое условие (match guard). Мы поговорим о сторожевых условиях позже в разделе «Дополнительные условия с помощью match guards».

Несколько образцов

Вы можете сопоставлять несколько образцов с помощью синтаксиса |, который является оператором «или» для образцов. Например, в следующем коде мы сопоставляем значение x с ветвями match, первая из которых имеет опцию «или», что означает, что если значение x соответствует любому из значений в этой ветви, выполнится код этой ветви:

fn main() {
    let x = 1;

    match x {
        1 | 2 => println!("one or two"),
        3 => println!("three"),
        _ => println!("anything"),
    }
}

Этот код выводит one or two.

Сопоставление диапазонов значений с ..=

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

fn main() {
    let x = 5;

    match x {
        1..=5 => println!("one through five"),
        _ => println!("something else"),
    }
}

Если x равен 1, 2, 3, 4 или 5, первая ветвь будет соответствовать. Этот синтаксис удобнее для нескольких значений сопоставления, чем использование оператора | для выражения той же идеи; если бы мы использовали |, нам пришлось бы указать 1 | 2 | 3 | 4 | 5. Указание диапазона гораздо короче, особенно если мы хотим сопоставить, скажем, любое число от 1 до 1000!

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

Вот пример использования диапазонов значений char:

fn main() {
    let x = 'c';

    match x {
        'a'..='j' => println!("early ASCII letter"),
        'k'..='z' => println!("late ASCII letter"),
        _ => println!("something else"),
    }
}

Rust может определить, что 'c' находится в диапазоне первого образца, и выводит early ASCII letter.

Деструктуризация для разбиения значений

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

Деструктуризация структур

Листинг 19-12 показывает структуру Point с двумя полями, x и y, которые мы можем разбить с помощью образца в операторе let.

Filename: src/main.rs
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    let Point { x: a, y: b } = p;
    assert_eq!(0, a);
    assert_eq!(7, b);
}
Listing 19-12: Деструктуризация полей структуры в отдельные переменные

Этот код создаёт переменные a и b, которые соответствуют значениям полей x и y структуры p. Этот пример показывает, что имена переменных в образце не обязательно должны совпадать с именами полей структуры. Однако обычно совмещают имена переменных с именами полей, чтобы было проще запомнить, из каких полей произошли переменные. Из-за этого распространённого использования и потому, что написание let Point { x: x, y: y } = p; содержит много дублирования, Rust имеет сокращение для образцов, которые соответствуют полям структуры: вам нужно только перечислить имя поля структуры, и переменные, созданные из образца, будут иметь те же имена. Листинг 19-13 ведёт себя так же, как код в листинге 19-12, но переменные, созданные в образце let, — это x и y вместо a и b.

Filename: src/main.rs
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    let Point { x, y } = p;
    assert_eq!(0, x);
    assert_eq!(7, y);
}
Listing 19-13: Деструктуризация полей структуры с помощью сокращения имён полей

Этот код создаёт переменные x и y, которые соответствуют полям x и y переменной p. Результат в том, что переменные x и y содержат значения из структуры p.

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

В листинге 19-14 у нас есть выражение match, которое разделяет значения Point на три случая: точки, лежащие непосредственно на оси x (что верно, когда y = 0), на оси y (x = 0) или ни на одной из осей.

Filename: src/main.rs
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    match p {
        Point { x, y: 0 } => println!("On the x axis at {x}"),
        Point { x: 0, y } => println!("On the y axis at {y}"),
        Point { x, y } => {
            println!("On neither axis: ({x}, {y})");
        }
    }
}
Listing 19-14: Деструктуризация и сопоставление литеральных значений в одном образце

Первая ветвь будет соответствовать любой точке, лежащей на оси x, указывая, что поле y соответствует, если его значение совпадает с литералом 0. Образец всё ещё создаёт переменную x, которую мы можем использовать в коде для этой ветви.

Аналогично, вторая ветвь соответствует любой точке на оси y, указывая, что поле x соответствует, если его значение равно 0, и создаёт переменную y для значения поля y. Третья ветвь не указывает никаких литералов, поэтому она соответствует любому другому Point и создаёт переменные как для поля x, так и для поля y.

В этом примере значение p соответствует второй ветви благодаря тому, что x содержит 0, поэтому этот код выведет On the y axis at 7.

Помните, что выражение match прекращает проверку ветвей, как только находит первый соответствующий образец, поэтому даже если Point { x: 0, y: 0 } лежит на оси x и на оси y, этот код выведет только On the x axis at 0.

Деструктуризация перечислений

Мы деструктурировали перечисления в этой книге (например, в листинге 6-5), но мы ещё явно не обсуждали, что образец для деструктуризации перечисления соответствует тому, как определены данные, хранящиеся внутри перечисления. В качестве примера, в листинге 19-15 мы используем перечисление Message из листинга 6-2 и пишем match с образцами, которые будут деструктурировать каждое внутреннее значение.

Filename: src/main.rs
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {
    let msg = Message::ChangeColor(0, 160, 255);

    match msg {
        Message::Quit => {
            println!("The Quit variant has no data to destructure.");
        }
        Message::Move { x, y } => {
            println!("Move in the x direction {x} and in the y direction {y}");
        }
        Message::Write(text) => {
            println!("Text message: {text}");
        }
        Message::ChangeColor(r, g, b) => {
            println!("Change color to red {r}, green {g}, and blue {b}");
        }
    }
}
Listing 19-15: Деструктуризация вариантов перечисления, содержащих разные виды значений

Этот код выведет Change color to red 0, green 160, and blue 255. Попробуйте изменить значение msg, чтобы увидеть выполнение кода из других ветвей.

Для вариантов перечисления без данных, таких как Message::Quit, мы не можем деструктурировать значение дальше. Мы можем только сопоставить с литеральным значением Message::Quit, и в этом образце нет переменных.

Для вариантов перечисления, похожих на структуры, таких как Message::Move, мы можем использовать образец, аналогичный образцу, который мы указываем для сопоставления структур. После имени варианта мы размещаем фигурные скобки, а затем перечисляем поля с переменными, чтобы разбить части для использования в коде для этой ветви. Здесь мы используем сокращённую форму, как в листинге 19-13.

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

Деструктуризация вложенных структур и перечислений

До сих пор наши примеры сопоставляли структуры или перечисления на одном уровне, но сопоставление может работать и с вложенными элементами! Например, мы можем рефакторинг код из листинга 19-15 для поддержки RGB и HSV цветов в сообщении ChangeColor, как показано в листинге 19-16.

enum Color {
    Rgb(i32, i32, i32),
    Hsv(i32, i32, i32),
}

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(Color),
}

fn main() {
    let msg = Message::ChangeColor(Color::Hsv(0, 160, 255));

    match msg {
        Message::ChangeColor(Color::Rgb(r, g, b)) => {
            println!("Change color to red {r}, green {g}, and blue {b}");
        }
        Message::ChangeColor(Color::Hsv(h, s, v)) => {
            println!("Change color to hue {h}, saturation {s}, value {v}");
        }
        _ => (),
    }
}
Listing 19-16: Сопоставление с вложенными перечислениями

Образец первой ветви в выражении match соответствует варианту перечисления Message::ChangeColor, который содержит вариант Color::Rgb; затем образец привязывается к трём внутренним значениям i32. Образец второй ветви также соответствует варианту перечисления Message::ChangeColor, но внутреннее перечисление соответствует Color::Hsv вместо этого. Мы можем указать эти сложные условия в одном выражении match, даже если задействовано два перечисления.

Деструктуризация структур и кортежей

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

fn main() {
    struct Point {
        x: i32,
        y: i32,
    }

    let ((feet, inches), Point { x, y }) = ((3, 10), Point { x: 3, y: -10 });
}

Этот код позволяет нам разбивать сложные типы на их составные части, чтобы мы могли использовать интересующие нас значения по отдельности.

Деструктуризация с образцами — это удобный способ использования частей значений, например значения из каждого поля в структуре, отдельно друг от друга.

Игнорирование значений в образце

Вы видели, что иногда полезно игнорировать значения в образце, например, в последней ветви match, чтобы получить универсальный обработчик, который ничего не делает, но учитывает все оставшиеся возможные значения. Есть несколько способов игнорировать целые значения или части значений в образце: использование образца _ (которое вы видели), использование образца _ внутри другого образца, использование имени, начинающегося с подчёркивания, или использование .. для игнорирования оставшихся частей значения. Давайте исследуем, как и почему использовать каждый из этих образцов.

Целое значение с _

Мы использовали подчёркивание как образец-шаблон, который будет соответствовать любому значению, но не привязываться к нему. Это особенно полезно как последняя ветвь в выражении match, но мы также можем использовать его в любом образце, включая параметры функции, как показано в листинге 19-17.

Filename: src/main.rs
fn foo(_: i32, y: i32) {
    println!("This code only uses the y parameter: {y}");
}

fn main() {
    foo(3, 4);
}
Listing 19-17: Использование _ в сигнатуре функции

Этот код полностью игнорирует значение 3, переданное как первый аргумент, и выведет This code only uses the y parameter: 4.

В большинстве случаев, когда вам больше не нужен определённый параметр функции, вы бы изменили сигнатуру, чтобы она не включала неиспользуемый параметр. Игнорирование параметра функции может быть особенно полезно в случаях, когда, например, вы реализуете типаж, где нужен определённый тип сигнатуры, но тело функции в вашей реализации не нуждается в одном из параметров. Тогда вы избегаете получения предупреждения компилятора о неиспользуемых параметрах функции, как если бы вы использовали имя вместо этого.

Части значения с вложенным _

Мы также можем использовать _ внутри другого образца, чтобы игнорировать только часть значения, например, когда мы хотим проверить только часть значения, но не нуждаемся в других частях в соответствующем коде, который мы хотим выполнить. Листинг 19-18 показывает код, отвечающий за управление значением настройки. Бизнес-требования таковы, что пользователю не должно быть разрешено перезаписывать существующую настройку, но он может сбросить настройку и задать ей значение, если она в данный момент не установлена.

fn main() {
    let mut setting_value = Some(5);
    let new_setting_value = Some(10);

    match (setting_value, new_setting_value) {
        (Some(_), Some(_)) => {
            println!("Can't overwrite an existing customized value");
        }
        _ => {
            setting_value = new_setting_value;
        }
    }

    println!("setting is {setting_value:?}");
}
Listing 19-18: Использование подчёркивания внутри образцов, которые соответствуют вариантам Some, когда нам не нужно использовать значение внутри Some

Этот код выведет Can't overwrite an existing customized value, а затем setting is Some(5). В первой ветви match нам не нужно сопоставлять или использовать значения внутри любого из вариантов Some, но нам нужно проверить случай, когда setting_value и new_setting_value являются вариантом Some. В этом случае мы выводим причину, по которой setting_value не изменяется, и она не изменяется.

Во всех остальных случаях (если либо setting_value, либо new_setting_value равен None), выраженных образцом _ во второй ветви, мы хотим разрешить setting_value быть установленным в new_setting_value.

Мы также можем использовать подчёркивания в нескольких местах внутри одного образца, чтобы игнорировать определённые значения. Листинг 19-19 показывает пример игнорирования второго и четвёртого значений в кортеже из пяти элементов.

fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (first, _, third, _, fifth) => {
            println!("Some numbers: {first}, {third}, {fifth}");
        }
    }
}
Listing 19-19: Игнорирование нескольких частей кортежа

Этот код выведет Some numbers: 2, 8, 32, а значения 4 и 16 будут проигнорированы.

Неиспользуемая переменная, начинающаяся с _

Если вы создаёте переменную, но нигде её не используете, Rust обычно выдаёт предупреждение, потому что неиспользуемая переменная может быть ошибкой. Однако иногда полезно иметь возможность создавать переменную, которую вы ещё не будете использовать, например, когда вы создаёте прототип или только начинаете проект. В такой ситуации вы можете сказать Rust не предупреждать вас о неиспользуемой переменной, начав имя переменной с подчёркивания. В листинге 19-20 мы создаём две неиспользуемые переменные, но при компиляции этого кода мы должны получить предупреждение только об одной из них.

Filename: src/main.rs
fn main() {
    let _x = 5;
    let y = 10;
}
Listing 19-20: Начало имени переменной с подчёркивания для избежания предупреждений о неиспользуемых переменных

Здесь мы получаем предупреждение о неиспользовании переменной y, но не получаем предупреждение о неиспользовании _x.

Обратите внимание, что есть тонкое различие между использованием только _ и использованием имени, начинающегося с подчёркивания. Синтаксис _x всё ещё привязывает значение к переменной, тогда как _ не привязывает вообще. Чтобы показать случай, когда это различие имеет значение, листинг 19-21 выдаст нам ошибку.

fn main() {
    let s = Some(String::from("Hello!"));

    if let Some(_s) = s {
        println!("found a string");
    }

    println!("{s:?}");
}
Listing 19-21: Неиспользуемая переменная, начинающаяся с подчёркивания, всё ещё привязывает значение, что может занять владение значением

Мы получим ошибку, потому что значение s всё ещё будет перемещено в _s, что мешает нам использовать s снова. Однако использование только подчёркивания никогда не привязывает к значению. Листинг 19-22 скомпилируется без ошибок, потому что s не перемещается в _.

fn main() {
    let s = Some(String::from("Hello!"));

    if let Some(_) = s {
        println!("found a string");
    }

    println!("{s:?}");
}
Listing 19-22: Использование подчёркивания не привязывает значение

Этот код работает perfectly, потому что мы никогда не привязываем s ни к чему; оно не перемещается.

Оставшиеся части значения с ..

Со значениями, имеющими много частей, мы можем использовать синтаксис .., чтобы использовать определённые части и игнорировать остальные, избегая необходимости перечислять подчёркивания для каждого игнорируемого значения. Образец .. игнорирует любые части значения, которые мы явно не сопоставили в остальной части образца. В листинге 19-23 у нас есть структура Point, которая хранит координату в трёхмерном пространстве. В выражении match мы хотим работать только с координатой x и игнорировать значения в полях y и z.

fn main() {
    struct Point {
        x: i32,
        y: i32,
        z: i32,
    }

    let origin = Point { x: 0, y: 0, z: 0 };

    match origin {
        Point { x, .. } => println!("x is {x}"),
    }
}
Listing 19-23: Игнорирование всех полей Point, кроме x, с помощью ..

Мы перечисляем значение x, а затем просто включаем образец ... Это быстрее, чем приходится перечислять y: _ и z: _, особенно когда мы работаем со структурами, имеющими много полей, в ситуациях, когда только одно или два поля релевантны.

Синтаксис .. расширится на столько значений, сколько ему нужно. Листинг 19-24 показывает, как использовать .. с кортежем.

Filename: src/main.rs
fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (first, .., last) => {
            println!("Some numbers: {first}, {last}");
        }
    }
}
Listing 19-24: Сопоставление только первого и последнего значений в кортеже и игнорирование всех остальных значений

В этом коде первое и последнее значения сопоставляются с first и last. .. сопоставит и проигнорирует всё посередине.

Однако использование .. должно быть однозначным. Если неясно, какие значения предназначены для сопоставления, а какие следует игнорировать, Rust выдаст нам ошибку. Листинг 19-25 показывает пример использования .. двусмысленно, поэтому он не скомпилируется.

Filename: src/main.rs
fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (.., second, ..) => {
            println!("Some numbers: {second}")
        },
    }
}
Listing 19-25: Попытка использовать .. двусмысленным образом

При компиляции этого примера мы получаем эту ошибку:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error: `..` can only be used once per tuple pattern
 --> src/main.rs:5:22
  |
5 |         (.., second, ..) => {
  |          --          ^^ can only be used once per tuple pattern
  |          |
  |          previously used here

error: could not compile `patterns` (bin "patterns") due to 1 previous error

Невозможно для Rust определить, сколько значений в кортеже игнорировать перед сопоставлением значения с second, а затем сколько дальнейших значений игнорировать после этого. Этот код может означать, что мы хотим игнорировать 2, связать second с 4, а затем игнорировать 8, 16 и 32; или что мы хотим игнорировать 2 и 4, связать second с 8, а затем игнорировать 16 и 32; и так далее. Имя переменной second не имеет особого значения для Rust, поэтому мы получаем ошибку компилятора, потому что использование .. в двух местах, как здесь, двусмысленно.

Дополнительные условия с помощью match guards

Сторожевой условие (match guard) — это дополнительное условие if, указанное после образца в ветви match, которое также должно соответствовать, чтобы эта ветвь была выбрана. Сторожевые условия полезны для выражения более сложных идей, чем позволяет один образец. Однако обратите внимание, что они доступны только в выражениях match, а не в выражениях if let или while let.

Условие может использовать переменные, созданные в образце. Листинг 19-26 показывает match, где первая ветвь имеет образец Some(x) и также имеет сторожевой условие if x % 2 == 0 (которое будет true, если число чётное).

fn main() {
    let num = Some(4);

    match num {
        Some(x) if x % 2 == 0 => println!("The number {x} is even"),
        Some(x) => println!("The number {x} is odd"),
        None => (),
    }
}
Listing 19-26: Добавление сторожевого условия к образцу

Этот пример выведет The number 4 is even. Когда num сравнивается с образцом в первой ветви, он соответствует, потому что Some(4) соответствует Some(x). Затем сторожевой условие проверяет, равен ли остаток от деления x на 2 нулю, и поскольку это так, выбирается первая ветвь.

Если бы num был Some(5) вместо этого, сторожевой условие в первой ветви было бы false, потому что остаток от деления 5 на 2 равен 1, что не равно 0. Rust тогда перешёл бы ко второй ветви, которая соответствовала бы, потому что вторая ветвь не имеет сторожевого условия и поэтому соответствует любому варианту Some.

Нет способа выразить условие if x % 2 == 0 внутри образца, поэтому сторожевой условие даёт нам возможность выразить эту логику. Недостаток этой дополнительной выразительности в том, что ветви со сторожевой условием не «считаются» при проверке исчерпывающего соответствия. Поэтому, даже если мы добавим Some(x) if x % 2 == 1 как дополнительную ветвь, нам всё равно понадобится незащищённая ветвь Some(x).

В листинге 19-11 мы упомянули, что можем использовать сторожевые условия, чтобы решить нашу проблему с затенением образцов. Вспомните, что мы создали новую переменную внутри образца в выражении match вместо использования переменной вне match. Эта новая переменная означала, что мы не могли проверить значение внешней переменной. Листинг 19-27 показывает, как мы можем использовать сторожевой условие, чтобы исправить эту проблему.

Filename: src/main.rs
fn main() {
    let x = Some(5);
    let y = 10;

    match x {
        Some(50) => println!("Got 50"),
        Some(n) if n == y => println!("Matched, n = {n}"),
        _ => println!("Default case, x = {x:?}"),
    }

    println!("at the end: x = {x:?}, y = {y}");
}
Listing 19-27: Использование сторожевого условия для проверки равенства с внешней переменной

Теперь этот код выведет Default case, x = Some(5). Образец во второй ветви match не вводит новую переменную y, которая бы скрывала внешнюю y, что означает, что мы можем использовать внешнюю y в сторожевом условии. Вместо того чтобы указывать образец как Some(y), что скрыло бы внешнюю y, мы указываем Some(n). Это создаёт новую переменную n, которая ничего не скрывает, потому что нет переменной n вне match.

Сторожевой условие if n == y не является образцом и поэтому не вводит новые переменные. Эта y является внешней y, а не новой y, скрывающей её, и мы можем искать значение, которое имеет то же значение, что и внешняя y, сравнивая n с y.

Вы также можете использовать оператор «или» | в сторожевом условии, чтобы указать несколько образцов; условие сторожевого условия будет применяться ко всем образцам. Листинг 19-28 показывает приоритет при объединении образца, который использует |, со сторожевой условием. Важная часть этого примера в том, что сторожевой условие if y применяется к 4, 5 и 6, даже если может показаться, что if y применяется только к 6.

fn main() {
    let x = 4;
    let y = false;

    match x {
        4 | 5 | 6 if y => println!("yes"),
        _ => println!("no"),
    }
}
Listing 19-28: Объединение нескольких образцов со сторожевой условием

Условие сопоставления гласит, что ветвь соответствует только если значение x равно 4, 5 или 6 и если y равно true. Когда этот код выполняется, образец первой ветви соответствует, потому что x равен 4, но сторожевой условие if y равно false, поэтому первая ветвь не выбирается. Код переходит ко второй ветви, которая соответствует, и эта программа выводит no. Причина в том, что условие if применяется ко всему образцу 4 | 5 | 6, а не только к последнему значению 6. Другими словами, приоритет сторожевого условия по отношению к образцу ведёт себя так:

(4 | 5 | 6) if y => ...

а не так:

4 | 5 | (6 if y) => ...

После запуска кода поведение приоритета очевидно: если бы сторожевой условие применялось только к последнему значению в списке значений, указанных с помощью оператора |, ветвь соответствовала бы, и программа вывела бы yes.

Привязки @

Оператор at @ позволяет нам создавать переменную, которая содержит значение, одновременно с проверкой этого значения на соответствие образцу. В листинге 19-29 мы хотим проверить, что поле id варианта Message::Hello находится в диапазоне 3..=7. Мы также хотим привязать значение к переменной id_variable, чтобы мы могли использовать его в коде, связанном с ветвью. Мы могли назвать эту переменную id, как поле, но для этого примера мы используем другое имя.

fn main() {
    enum Message {
        Hello { id: i32 },
    }

    let msg = Message::Hello { id: 5 };

    match msg {
        Message::Hello {
            id: id_variable @ 3..=7,
        } => println!("Found an id in range: {id_variable}"),
        Message::Hello { id: 10..=12 } => {
            println!("Found an id in another range")
        }
        Message::Hello { id } => println!("Found some other id: {id}"),
    }
}
Listing 19-29: Использование @ для привязки к значению в образце, одновременно проверяя его

Этот пример выведет Found an id in range: 5. Указав id_variable @ перед диапазоном 3..=7, мы захватываем любое значение, соответствующее диапазону, одновременно проверяя, что значение соответствует образцу диапазона.

Во второй ветви, где у нас указан только диапазон в образце, код, связанный с ветвью, не имеет переменной, содержащей фактическое значение поля id. Значение поля id могло быть 10, 11 или 12, но код, идущий с этим образцом, не знает, каким оно является. Код образца не может использовать значение из поля id, потому что мы не сохранили значение id в переменной.

В последней ветви, где мы указали переменную без диапазона, у нас есть значение, доступное для использования в коде ветви в переменной с именем id. Причина в том, что мы использовали сокращённый синтаксис имён полей структуры. Но мы не применили никакой проверки к значению в поле id в этой ветви, как сделали в первых двух ветвях: любое значение соответствовало бы этому образцу.

Использование @ позволяет нам проверить значение и сохранить его в переменной внутри одного образца.

Краткое содержание

Образцы Rust очень полезны для различения разных видов данных. При использовании в выражениях match Rust гарантирует, что ваши образцы покрывают каждое возможное значение, иначе ваша программа не скомпилируется. Образцы в операторах let и параметрах функций делают эти конструкции более полезными, позволяя деструктурировать значения на более мелкие части одновременно с присвоением этих частей переменным. Мы можем создавать простые или сложные образцы в соответствии с нашими потребностями.

Далее, в предпоследней главе книги, мы рассмотрим некоторые продвинутые аспекты различных возможностей Rust.

Расширенные возможности

К этому моменту вы уже освоили наиболее часто используемые части языка Rust. Перед тем как перейти к следующему проекту в главе 21, мы рассмотрим несколько аспектов языка, с которыми вы можете время от времени сталкиваться, но которые используются не каждый день. Эту главу можно использовать как справочник, когда вы встречаете незнакомые конструкции. Описываемые здесь возможности полезны в очень специфических ситуациях. Хотя вы можете обращаться к ним нечасто, мы хотим убедиться, что вы понимаете все возможности, которые предлагает Rust.

В этой главе мы рассмотрим:

  • Небезопасный Rust: как отказаться от некоторых гарантий Rust и взять на себя ответственность за их ручное соблюдение
  • Расширенные типажи: ассоциированные типы, параметры типов по умолчанию, полностью квалифицированный синтаксис, супертипажи и паттерн newtype в контексте типажей
  • Расширенные типы: подробнее о паттерне newtype, псевдонимы типов, тип never и динамически sized типы
  • Расширенные функции и замыкания: указатели на функции и возврат замыканий
  • Макросы: способы определения кода, который генерирует дополнительный код на этапе компиляции

Это набор возможностей Rust на любой вкус! Приступим!

Небезопасный Rust

Весь код, который мы обсуждали до сих пор, имел гарантии безопасности памяти Rust, проверяемые на этапе компиляции. Однако внутри Rust существует второй язык, который не обеспечивает эти гарантии: он называется небезопасным Rust и работает так же, как обычный Rust, но даёт нам дополнительные сверхспособности.

Небезопасный Rust существует потому, что статический анализ по своей природе консервативен. Когда компилятор пытается определить, соблюдает ли код гарантии, лучше отвергнуть некоторые корректные программы, чем принять некорректные. Хотя код может быть правильным, если у компилятора Rust недостаточно информации для уверенности, он отклонит код. В таких случаях вы можете использовать небезопасный код, чтобы сказать компилятору: «Доверься, я знаю, что делаю». Однако предупреждаем: вы используете небезопасный Rust на свой страх и риск: если вы используете небезопасный код некорректно, могут возникнуть проблемы из-за нарушения безопасности памяти, такие как разыменование нулевого указателя.

Другая причина, по которой у Rust есть небезопасный alter ego, в том, что аппаратное обеспечение компьютера изначально небезопасно. Если бы Rust не позволял выполнять небезопасные операции, вы не смогли бы решать определённые задачи. Rust должен позволять вам заниматься низкоуровневым системным программированием, например, напрямую взаимодействовать с операционной системой или даже писать свою собственную ОС. Работа с низкоуровневым системным программированием — одна из целей языка. Давайте исследуем, что мы можем делать с небезопасным Rust и как это делать.

Сверхспособности небезопасного Rust

Чтобы переключиться на небезопасный Rust, используйте ключевое слово unsafe и затем начните новый блок, содержащий небезопасный код. Вы можете выполнять в небезопасном Rust пять действий, которые не можете в безопасном Rust, что мы называем сверхспособностями небезопасного Rust. Эти сверхспособности включают возможность:

  • Разыменовывать сырой указатель
  • Вызывать небезопасную функцию или метод
  • Обращаться к изменяемой статической переменной или изменять её
  • Реализовывать небезопасный типаж
  • Обращаться к полям union

Важно понимать, что unsafe не отключает проверку заимствований или другие проверки безопасности Rust: если вы используете ссылку в небезопасном коде, она всё равно будет проверяться. Ключевое слово unsafe только даёт доступ к этим пяти возможностям, которые затем не проверяются компилятором на безопасность памяти. Вы всё ещё получите некоторую степень безопасности внутри блока unsafe.

Кроме того, unsafe не означает, что код внутри блока обязательно опасен или что он определённо будет иметь проблемы с безопасностью памяти: предполагается, что как программист вы обеспечите доступ к памяти в блоке unsafe корректным способом.

Люди подвержены ошибкам, и ошибки случаются, но требуя, чтобы эти пять небезопасных операций были внутри блоков, аннотированных unsafe, вы будете знать, что любые ошибки, связанные с безопасностью памяти, должны находиться внутри блока unsafe. Делайте блоки unsafe небольшими; вы будете благодарны позже, когда будете исследовать ошибки памяти.

Чтобы изолировать небезопасный код как можно больше, лучше всего заключать такой код внутри безопасной абстракции и предоставлять безопасный API, что мы обсудим позже в этой главе, когда рассмотрим небезопасные функции и методы. Части стандартной библиотеки реализованы как безопасные абстракции над небезопасным кодом, который прошёл аудит. Обёртывание небезопасного кода в безопасную абстракцию предотвращает «просачивание» использования unsafe во все места, где вы или ваши пользователи могли бы захотеть использовать функциональность, реализованную с помощью небезопасного кода, поскольку использование безопасной абстракции безопасно.

Давайте рассмотрим каждую из пяти сверхспособностей небезопасного Rust по очереди. Мы также посмотрим на некоторые абстракции, которые предоставляют безопасный интерфейс для небезопасного кода.

Разыменование сырого указателя

В разделе «Проверка прав доступа» главы 4 мы описали, как компилятор обеспечивает, чтобы ссылки всегда были корректными. Небезопасный Rust имеет два новых типа, называемых сырыми указателями, которые похожи на ссылки. Как и ссылки, сырые указатели могут быть неизменяемыми или изменяемыми и записываются как *const T и *mut T соответственно. Звёздочка — это не оператор разыменования; это часть имени типа. В контексте сырых указателей неизменяемый означает, что указатель не может быть напрямую присвоен после разыменования.

В отличие от ссылок и умных указателей, сырые указатели:

  • Могут игнорировать правила заимствования, имея одновременно как неизменяемые, так и изменяемые указатели или несколько изменяемых указателей на одно и то же место
  • Не гарантируют, что указывают на действительную память
  • Могут быть нулевыми
  • Не реализуют автоматическую очистку

Отказываясь от гарантий Rust, вы можете пожертвовать гарантированной безопасностью в обмен на большую производительность или возможность взаимодействовать с другим языком или оборудованием, где гарантии Rust не применяются.

Листинг 20-1 показывает, как создать неизменяемый и изменяемый сырой указатель.

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;
}
Listing 20-1: Создание сырых указателей с помощью операторов сырого заимствования

Обратите внимание, что мы не включаем ключевое слово unsafe в этот код. Мы можем создавать сырые указатели в безопасном коде; мы просто не можем разыменовывать сырые указатели вне блока unsafe, как вы увидите чуть позже.

Мы создали сырые указатели, используя операторы сырого заимствования: &raw const num создаёт неизменяемый сырой указатель *const i32, а &raw mut num создаёт изменяемый сырой указатель *mut i32. Поскольку мы создали их непосредственно из локальной переменной, мы знаем, что эти конкретные сырые указатели действительны, но мы не можем сделать такое предположение о любом сыром указателе.

Чтобы это продемонстрировать, далее мы создадим сырой указатель, о действительности которого мы не можем быть так уверены, используя as для приведения значения вместо использования операторов сырого заимствования. Листинг 20-2 показывает, как создать сырой указатель на произвольный адрес в памяти. Попытка использовать произвольную память неопределена: в этом адресе могут быть данные или их не может, компилятор может оптимизировать код так, чтобы не было доступа к памяти, или программа может завершиться с ошибкой сегментации. Обычно нет веской причины писать такой код, особенно в случаях, когда можно использовать оператор сырого заимствования, но это возможно.

fn main() {
    let address = 0x012345usize;
    let r = address as *const i32;
}
Listing 20-2: Создание сырого указателя на произвольный адрес памяти

Вспомните, что мы можем создавать сырые указатели в безопасном коде, но мы не можем разыменовывать сырые указатели и читать данные, на которые они указывают. В Листинге 20-3 мы используем оператор разыменования * на сыром указателе, что требует блока unsafe.

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;

    unsafe {
        println!("r1 is: {}", *r1);
        println!("r2 is: {}", *r2);
    }
}
Listing 20-3: Разыменование сырых указателей внутри блока unsafe

Создание указателя не причиняет вреда; проблема возникает только тогда, когда мы пытаемся получить доступ к значению, на которое он указывает.

Обратите также внимание, что в Листинге 20-1 и 20-3 мы создали сырые указатели *const i32 и *mut i32, которые оба указывали на одно и то же место в памяти, где хранится num. Если бы вместо этого мы попытались создать неизменяемую и изменяемую ссылки на num, код не скомпилировался бы, потому что правила владения Rust не позволяют иметь изменяемую ссылку одновременно с любыми неизменяемыми ссылками. С сырыми указателями мы можем создать изменяемый указатель и неизменяемый указатель на одно и то же место и изменить данные через изменяемый указатель, потенциально создавая гонку данных. Будьте осторожны!

При всех этих опасностях зачем вообще использовать сырые указатели? Один из основных вариантов использования — при взаимодействии с кодом на C, как вы увидите в следующем разделе, «Вызов небезопасной функции или метода.» Другой случай — при построении безопасных абстракций, которые проверка заимствований не понимает. Мы представим небезопасные функции, а затем рассмотрим пример безопасной абстракции, использующей небезопасный код.

Вызов небезопасной функции или метода

Второй тип операции, которую вы можете выполнить в блоке unsafe, — это вызов небезопасных функций. Небезопасные функции и методы выглядят точно так же, как обычные функции и методы, но имеют дополнительное unsafe перед остальной частью определения. Ключевое слово unsafe в этом контексте указывает, что у функции есть требования, которые нам нужно соблюдать при вызове этой функции, потому что Rust не может гарантировать, что мы выполнили эти требования. Вызывая небезопасную функцию внутри блока unsafe, мы говорим, что прочитали документацию этой функции и берём на себя ответственность за соблюдение контрактов функции.

Вот небезопасная функция с именем dangerous, которая ничего не делает в своём теле:

fn main() {
    unsafe fn dangerous() {}

    unsafe {
        dangerous();
    }
}

Мы должны вызвать функцию dangerous внутри отдельного блока unsafe. Если мы попытаемся вызвать dangerous без блока unsafe, мы получим ошибку:

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0133]: call to unsafe function `dangerous` is unsafe and requires unsafe block
 --> src/main.rs:4:5
  |
4 |     dangerous();
  |     ^^^^^^^^^^^ call to unsafe function
  |
  = note: consult the function's documentation for information on how to avoid undefined behavior

For more information about this error, try `rustc --explain E0133`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error

С блоком unsafe мы утверждаем для Rust, что прочитали документацию функции, понимаем, как правильно её использовать, и проверили, что выполняем контракт функции.

Чтобы выполнять небезопасные операции в теле небезопасной функции, вам всё равно нужно использовать блок unsafe, как и внутри обычной функции, и компилятор предупредит вас, если вы забудете. Это помогает сохранять блоки unsafe как можно меньшими, поскольку небезопасные операции могут быть не нужны во всём теле функции.

Создание безопасной абстракции над небезопасным кодом

То, что функция содержит небезопасный код, не означает, что нам нужно помечать всю функцию как небезопасную. На самом деле, обёртывание небезопасного кода в безопасную функцию — распространённая абстракция. В качестве примера изучим функцию split_at_mut из стандартной библиотеки, которая требует некоторого небезопасного кода. Мы исследуем, как мы могли бы её реализовать. Этот безопасный метод определён для изменяемых срезов: он принимает один срез и разбивает его на два по индексу, заданному в качестве аргумента. Листинг 20-4 показывает, как использовать split_at_mut.

fn main() {
    let mut v = vec![1, 2, 3, 4, 5, 6];

    let r = &mut v[..];

    let (a, b) = r.split_at_mut(3);

    assert_eq!(a, &mut [1, 2, 3]);
    assert_eq!(b, &mut [4, 5, 6]);
}
Listing 20-4: Использование безопасной функции split_at_mut

Мы не можем реализовать эту функцию, используя только безопасный Rust. Попытка может выглядеть примерно как в Листинге 20-5, который не скомпилируется. Для простоты мы реализуем split_at_mut как функцию, а не как метод, и только для срезов значений i32, а не для общего типа T.

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();

    assert!(mid <= len);

    (&mut values[..mid], &mut values[mid..])
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}
Listing 20-5: Попытка реализации split_at_mut с использованием только безопасного Rust

Эта функция сначала получает общую длину среза. Затем она утверждает, что индекс, заданный в качестве параметра, находится внутри среза, проверяя, меньше ли он или равен длине. Это утверждение означает, что если мы передадим индекс, больший длины, для разделения среза, функция аварийно завершится до попытки использовать этот индекс.

Затем мы возвращаем два изменяемых среза в кортеже: один от начала исходного среза до индекса mid и другой от mid до конца среза.

Когда мы пытаемся скомпилировать код в Листинге 20-5, мы получим ошибку.

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0499]: cannot borrow `*values` as mutable more than once at a time
 --> src/main.rs:6:31
  |
1 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
  |                         - let's call the lifetime of this reference `'1`
...
6 |     (&mut values[..mid], &mut values[mid..])
  |     --------------------------^^^^^^--------
  |     |     |                   |
  |     |     |                   second mutable borrow occurs here
  |     |     first mutable borrow occurs here
  |     returning this value requires that `*values` is borrowed for `'1`
  |
  = help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices

For more information about this error, try `rustc --explain E0499`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error

Проверка заимствований Rust не может понять, что мы заимствуем разные части среза; она знает только, что мы заимствуем из одного и того же среза дважды. Заимствование разных частей среза по сути корректно, потому что два среза не перекрываются, но Rust недостаточно умен, чтобы это знать. Когда мы знаем, что код корректен, но Rust не знает, наступает время обратиться к небезопасному коду.

Листинг 20-6 показывает, как использовать блок unsafe, сырой указатель и некоторые вызовы небезопасных функций, чтобы заставить реализацию split_at_mut работать.

use std::slice;

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();
    let ptr = values.as_mut_ptr();

    assert!(mid <= len);

    unsafe {
        (
            slice::from_raw_parts_mut(ptr, mid),
            slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}
Listing 20-6: Использование небезопасного кода в реализации функции split_at_mut

Вспомните из раздела «Тип среза» главы 4, что срезы — это указатель на некоторые данные и длина среза. Мы используем метод len для получения длины среза и метод as_mut_ptr для доступа к сырому указателю среза. В этом случае, поскольку у нас есть изменяемый срез значений i32, as_mut_ptr возвращает сырой указатель с типом *mut i32, который мы сохранили в переменной ptr.

Мы сохраняем утверждение, что индекс mid находится внутри среза. Затем мы переходим к небезопасному коду: функция slice::from_raw_parts_mut принимает сырой указатель и длину и создаёт срез. Мы используем её для создания среза, который начинается с ptr и имеет длину mid элементов. Затем мы вызываем метод add на ptr с аргументом mid, чтобы получить сырой указатель, который начинается с mid, и создаём срез, используя этот указатель и оставшееся количество элементов после mid в качестве длины.

Функция slice::from_raw_parts_mut небезопасна, потому что она принимает сырой указатель и должна доверять, что этот указатель действителен. Метод add для сырых указателей также небезопасен, потому что он должен доверять, что смещённое местоположение также является действительным указателем. Поэтому нам пришлось поместить блок unsafe вокруг наших вызовов slice::from_raw_parts_mut и add, чтобы мы могли их вызвать. Посмотрев на код и добавив утверждение, что mid должен быть меньше или равен len, мы можем сказать, что все сырые указатели, используемые внутри блока unsafe, будут действительными указателями на данные внутри среза. Это приемлемое и уместное использование unsafe.

Обратите внимание, что нам не нужно помечать результирующую функцию split_at_mut как unsafe, и мы можем вызвать эту функцию из безопасного Rust. Мы создали безопасную абстракцию для небезопасного кода с реализацией функции, которая использует небезопасный код безопасным способом, потому что она создаёт только действительные указатели из данных, к которым эта функция имеет доступ.

В отличие от этого, использование slice::from_raw_parts_mut в Листинге 20-7, скорее всего, упадёт при использовании среза. Этот код берёт произвольное местоположение в памяти и создаёт срез длиной 10 000 элементов.

fn main() {
    use std::slice;

    let address = 0x01234usize;
    let r = address as *mut i32;

    let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
}
Listing 20-7: Создание среза из произвольного местоположения в памяти

Мы не владеем памятью в этом произвольном местоположении, и нет гарантии, что срез, который создаёт этот код, содержит действительные значения i32. Попытка использовать values как действительный срез приводит к неопределённому поведению.

Использование функций extern для вызова внешнего кода

Иногда вашему коду на Rust может потребоваться взаимодействовать с кодом, написанным на другом языке. Для этого Rust имеет ключевое слово extern, которое облегчает создание и использование внешнего интерфейса функций (FFI). FFI — это способ для языка программирования определить функции и позволить другому (иностранному) языку программирования вызывать эти функции.

Листинг 20-8 демонстрирует, как настроить интеграцию с функцией abs из стандартной библиотеки C. Функции, объявленные внутри блоков extern, обычно небезопасны для вызова из кода Rust, поэтому блоки extern также должны быть помечены unsafe. Причина в том, что другие языки не обеспечивают правила и гарантии Rust, и Rust не может их проверить, поэтому ответственность ложится на программиста для обеспечения безопасности.

Filename: src/main.rs
unsafe extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!("Absolute value of -3 according to C: {}", abs(-3));
    }
}
Listing 20-8: Объявление и вызов функции extern, определённой на другом языке

Внутри блока unsafe extern "C" мы перечисляем имена и сигнатуры внешних функций из другого языка, которые мы хотим вызвать. Часть "C" определяет, какой бинарный интерфейс приложения (ABI) использует внешняя функция: ABI определяет, как вызывать функцию на уровне сборки. ABI "C" — самый распространённый и следует ABI языка программирования C. Информация обо всех ABI, которые поддерживает Rust, доступна в справочнике Rust.

Каждый элемент, объявленный внутри блока unsafe extern, подразумевается unsafe. Однако некоторые функции FFI являются безопасными для вызова. Например, функция abs из стандартной библиотеки C не имеет никаких соображений безопасности памяти, и мы знаем, что её можно вызвать с любым i32. В таких случаях мы можем использовать ключевое слово safe, чтобы сказать, что эта конкретная функция безопасна для вызова, даже если она находится в блоке unsafe extern. После этого изменения её вызов больше не требует блока unsafe, как показано в Листинге 20-9.

Filename: src/main.rs
unsafe extern "C" {
    safe fn abs(input: i32) -> i32;
}

fn main() {
    println!("Absolute value of -3 according to C: {}", abs(-3));
}
Listing 20-9: Явное помечение функции как safe внутри блока unsafe extern и безопасный вызов

Пометка функции как safe по сути не делает её безопасной! Вместо этого это как обещание, которое вы даёте Rust, что она является безопасной. Всё ещё ваша ответственность убедиться, что это обещание выполняется!

Вызов функций Rust из других языков

Мы также можем использовать extern для создания интерфейса, который позволяет другим языкам вызывать функции Rust. Вместо создания целого блока extern мы добавляем ключевое слово extern и указываем ABI для использования непосредственно перед ключевым словом fn для соответствующей функции. Нам также нужно добавить аннотацию #[unsafe(no_mangle)], чтобы сказать компилятору Rust не выполнять манглирование имени этой функции. Манглирование — это когда компилятор изменяет имя, которое мы дали функции, на другое имя, содержащее больше информации для потребления другими частями процесса компиляции, но менее читаемое человеком. Каждый компилятор языка программирования выполняет манглирование имён немного по-разному, поэтому для того чтобы функция Rust была доступна по имени для других языков, мы должны отключить манглирование имён компилятором Rust. Это небезопасно, потому что могут возникать коллизии имён между библиотеками без встроенного манглирования, поэтому наша ответственность убедиться, что выбранное нами имя безопасно для экспорта без манглирования.

В следующем примере мы делаем функцию call_from_c доступной из кода на C после её компиляции в общую библиотеку и связывания из C:

#![allow(unused)]
fn main() {
#[unsafe(no_mangle)]
pub extern "C" fn call_from_c() {
    println!("Just called a Rust function from C!");
}
}

Это использование extern требует unsafe только в аннотации, а не в блоке extern.

Обращение к изменяемой статической переменной или её изменение

В этой книге мы ещё не говорили о глобальных переменных, которые Rust поддерживает, но которые могут быть проблематичными с правилами владения Rust. Если два потока обращаются к одной и той же изменяемой глобальной переменной, это может вызвать гонку данных.

В Rust глобальные переменные называются статическими переменными. Листинг 20-10 показывает пример объявления и использования статической переменной со строковым срезом в качестве значения.

Filename: src/main.rs
static HELLO_WORLD: &str = "Hello, world!";

fn main() {
    println!("name is: {HELLO_WORLD}");
}
Listing 20-10: Определение и использование неизменяемой статической переменной

Статические переменные похожи на константы, которые мы обсудили в разделе «Константы» главы 3. Имена статических переменных по соглашению записываются в SCREAMING_SNAKE_CASE. Статические переменные могут хранить только ссылки со временем жизни 'static, что означает, что компилятор Rust может определить время жизни, и нам не требуется явно его аннотировать. Обращение к неизменяемой статической переменной безопасно.

Тонкое различие между константами и неизменяемыми статическими переменными в том, что значения в статической переменной имеют фиксированный адрес в памяти. Использование значения всегда будет обращаться к одним и тем же данным. Константы, с другой стороны, могут дублировать свои данные каждый раз при использовании. Другое различие в том, что статические переменные могут быть изменяемыми. Обращение к изменяемым статическим переменным и их изменение небезопасно. Листинг 20-11 показывает, как объявить, обратиться и изменить изменяемую статическую переменную с именем COUNTER.

Filename: src/main.rs
static mut COUNTER: u32 = 0;

/// SAFETY: Calling this from more than a single thread at a time is undefined
/// behavior, so you *must* guarantee you only call it from a single thread at
/// a time.
unsafe fn add_to_count(inc: u32) {
    unsafe {
        COUNTER += inc;
    }
}

fn main() {
    unsafe {
        // SAFETY: This is only called from a single thread in `main`.
        add_to_count(3);
        println!("COUNTER: {}", *(&raw const COUNTER));
    }
}
Listing 20-11: Чтение из изменяемой статической переменной или запись в неё небезопасно

Как и с обычными переменными, мы указываем изменяемость с помощью ключевого слова mut. Любой код, который читает или записывает в COUNTER, должен находиться внутри блока unsafe. Этот код компилируется и печатает COUNTER: 3, как мы ожидаем, потому что он однопоточный. Наличие нескольких потоков, обращающихся к COUNTER, скорее всего, приведёт к гонкам данных, поэтому это неопределённое поведение. Следовательно, нам нужно пометить всю функцию как unsafe и документировать ограничение безопасности, чтобы любой, вызывающий функцию, знал, что они могут и не могут безопасно делать.

Всякий раз, когда мы пишем небезопасную функцию, идиоматично написать комментарий, начинающийся с SAFETY, и объяснить, что вызывающий должен сделать, чтобы безопасно вызвать функцию. Аналогично, всякий раз, когда мы выполняем небезопасную операцию, идиоматично написать комментарий, начинающийся с SAFETY, чтобы объяснить, как соблюдаются правила безопасности.

Кроме того, компилятор не позволит вам создавать ссылки на изменяемую статическую переменную. Вы можете обращаться к ней только через сырой указатель, созданный одним из операторов сырого заимствования. Это включает случаи, когда ссылка создаётся неявно, как при использовании в println! в этом листинге кода. Требование, чтобы ссылки на изменяемые статические переменные могли создаваться только через сырые указатели, помогает сделать требования безопасности для их использования более очевидными.

С изменяемыми данными, доступными глобально, трудно убедиться, что нет гонок данных, поэтому Rust считает изменяемые статические переменные небезопасными. Где возможно, предпочтительнее использовать техники конкурентности и потокобезопасные умные указатели, которые мы обсуждали в главе 16, чтобы компилятор проверял, что доступ к данным из разных потоков выполняется безопасно.

Реализация небезопасного типажа

Мы можем использовать unsafe для реализации небезопасного типажа. Типаж небезопасен, когда хотя бы один из его методов имеет некоторое инвариантное условие, которое компилятор не может проверить. Мы объявляем, что типаж является unsafe, добавляя ключевое слово unsafe перед trait и помечая реализацию типажа также как unsafe, как показано в Листинге 20-12.

unsafe trait Foo {
    // methods go here
}

unsafe impl Foo for i32 {
    // method implementations go here
}

fn main() {}
Listing 20-12: Определение и реализация небезопасного типажа

Используя unsafe impl, мы обещаем, что будем соблюдать инварианты, которые компилятор не может проверить.

В качестве примера вспомните маркерные типажи Sync и Send, которые мы обсуждали в разделе «Расширяемая конкурентность с типажами Sync и Send» главы 16: компилятор реализует эти типажи автоматически, если наши типы состоят целиком из других типов, которые реализуют Send и Sync. Если мы реализуем тип, содержащий тип, который не реализует Send или Sync, такой как сырые указатели, и хотим пометить этот тип как Send или Sync, мы должны использовать unsafe. Rust не может проверить, что наш тип соблюдает гарантии, что его можно безопасно отправлять между потоками или к нему можно обращаться из нескольких потоков; следовательно, нам нужно выполнять эти проверки вручную и указывать на это с помощью unsafe.

Обращение к полям union

Последнее действие, которое работает только с unsafe, — это обращение к полям union. Union похож на struct, но только одно объявленное поле используется в конкретном экземпляре одновременно. Union в основном используются для взаимодействия с union в коде на C. Обращение к полям union небезопасно, потому что Rust не может гарантировать тип данных, который в данный момент хранится в экземпляре union. Вы можете узнать больше о union в справочнике Rust.

Использование Miri для проверки небезопасного кода

При написании небезопасного кода вы можете захотеть проверить, что написанное вами действительно безопасно и корректно. Один из лучших способов сделать это — использовать Miri, официальный инструмент Rust для обнаружения неопределённого поведения. В то время как проверка заимствований — это статический инструмент, работающий на этапе компиляции, Miri — это динамический инструмент, работающий во время выполнения. Он проверяет ваш код, запуская вашу программу или её набор тестов, и обнаруживает, когда вы нарушаете правила, которые он понимает о том, как должен работать Rust.

Использование Miri требует ночной сборки Rust (о которой мы говорим более подробно в Приложении G: Как создаётся Rust и «Ночной Rust»). Вы можете установить как ночную версию Rust, так и инструмент Miri, введя rustup +nightly component add miri. Это не изменяет версию Rust, которую использует ваш проект; оно только добавляет инструмент в вашу систему, чтобы вы могли использовать его, когда захотите. Вы можете запустить Miri для проекта, введя cargo +nightly miri run или cargo +nightly miri test.

В качестве примера того, насколько это полезно, рассмотрим, что происходит, когда мы запускаем его для Листинга 20-11.

$ cargo +nightly miri run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
     Running `file:///home/.rustup/toolchains/nightly/bin/cargo-miri runner target/miri/debug/unsafe-example`
COUNTER: 3

Miri правильно предупреждает нас, что у нас есть разделяемые ссылки на изменяемые данные. Здесь Miri выдаёт только предупреждение, потому что это не гарантированно неопределённое поведение в этом случае, и он не говорит нам, как исправить проблему. Но по крайней мере мы знаем, что есть риск неопределённого поведения, и можем подумать о том, как сделать код безопасным. В некоторых случаях Miri также может обнаружить явные ошибки — шаблоны кода, которые точно неверны, — и давать рекомендации о том, как исправить эти ошибки.

Miri не ловит всё, что вы можете сделать не так при написании небезопасного кода. Miri — это инструмент динамического анализа, поэтому он обнаруживает только проблемы с кодом, который фактически выполняется. Это означает, что вам нужно будет использовать его вместе с хорошими техниками тестирования, чтобы повысить уверенность в небезопасном коде, который вы написали. Miri также не охватывает все возможные способы, которыми ваш код может быть некорректным.

Иначе говоря: если Miri обнаруживает проблему, вы знаете, что есть ошибка, но просто потому, что Miri не обнаруживает ошибку, это не значит, что проблемы нет. Однако он может обнаружить многое. Попробуйте запустить его на других примерах небезопасного кода в этой главе и посмотрите, что он говорит!

Вы можете узнать больше о Miri на его репозитории GitHub.

Когда использовать небезопасный код

Использование unsafe для использования одной из пяти сверхспособностей, только что обсуждённых, не является неправильным или даже осуждаемым, но получить небезопасный код корректным сложнее, потому что компилятор не может помочь обеспечить безопасность памяти. Когда у вас есть причина использовать небезопасный код, вы можете это сделать, и наличие явной аннотации unsafe облегчает отслеживание источника проблем, когда они возникают. Всякий раз, когда вы пишете небезопасный код, вы можете использовать Miri, чтобы помочь вам быть более уверенным в том, что написанный вами код соблюдает правила Rust.

Для гораздо более глубокого исследования того, как эффективно работать с небезопасным Rust, прочитайте официальное руководство Rust по этой теме, Rustonomicon.

Продвинутые типажи

Мы уже знакомились с типажами в разделе «Типажи: определение общего поведения» в главе 10, но не обсуждали более сложные детали. Теперь, когда вы знаете Rust лучше, мы можем разобраться в нюансах.

Связанные типы

Связанные типы связывают заполнитель типа с типажом так, что определения методов типажа могут использовать эти заполнители в своих сигнатурах. Реализатор типажа укажет конкретный тип, который будет использоваться вместо заполнителя для данной конкретной реализации. Таким образом, мы можем определить типаж, использующий некоторые типы, без необходимости знать точно, какие это типы, пока типаж не реализован.

Мы описали большинство продвинутых возможностей этой главы как редко нужные. Связанные типы находятся где-то посередине: они используются реже, чем возможности, объяснённые в остальной части книги, но чаще, чем многие другие возможности, обсуждаемые в этой главе.

Одним из примеров типажа со связанным типом является типаж Iterator, предоставляемый стандартной библиотекой. Связанный тип называется Item и заменяет тип значений, по которым тип, реализующий типаж Iterator, выполняет итерацию. Определение типажа Iterator показано в листинге 20-13.

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}
Listing 20-13: Определение типажа Iterator, имеющего связанный тип Item

Тип Item — это заполнитель, и определение метода next показывает, что оно будет возвращать значения типа Option<Self::Item>. Реализаторы типажа Iterator укажут конкретный тип для Item, и метод next вернёт Option, содержащий значение этого конкретного типа.

Связанные типы могут показаться похожей концепцией на обобщения (generics), поскольку последние позволяют определить функцию без указания типов, с которыми она может работать. Чтобы изучить разницу между этими концепциями, мы рассмотрим реализацию типажа Iterator для типа Counter, где указано, что тип Item равен u32:

Filename: src/lib.rs
struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        // --snip--
        if self.count < 5 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

Этот синтаксис сравним с синтаксисом обобщений. Так почему бы просто не определить типаж Iterator с обобщениями, как показано в листинге 20-14?

pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}
Listing 20-14: Гипотетическое определение типажа Iterator с использованием обобщений

Разница в том, что при использовании обобщений, как в листинге 20-14, мы должны аннотировать типы в каждой реализации; поскольку мы также можем реализовать Iterator<String> for Counter или любой другой тип, у нас может быть несколько реализаций Iterator для Counter. Другими словами, когда типаж имеет параметр обобщения, его можно реализовать для типа несколько раз, каждый раз изменяя конкретные типы параметров обобщения. При использовании метода next на Counter нам пришлось бы предоставить аннотации типов, чтобы указать, какую реализацию Iterator мы хотим использовать.

При использовании связанных типов нам не нужно аннотировать типы, потому что мы не можем реализовать типаж для типа несколько раз. В листинге 20-13 с определением, использующим связанные типы, мы можем выбрать тип Item только один раз, поскольку может быть только одна impl Iterator for Counter. Нам не нужно указывать, что мы хотим итератор значений u32 везде, где вызываем next на Counter.

Связанные типы также становятся частью контракта типажа: реализаторы типажа должны предоставить тип для замены заполнителя связанного типа. Связанные типы часто имеют имя, описывающее, как тип будет использоваться, и документирование связанного типа в документации API — это хорошая практика.

Параметры типа по умолчанию и перегрузка операторов

При использовании параметров обобщения мы можем указать конкретный тип по умолчанию для обобщённого типа. Это устраняет необходимость для реализаторов типажа указывать конкретный тип, если тип по умолчанию подходит. Вы указываете тип по умолчанию при объявлении обобщённого типа с синтаксисом <ЗаполнительТипа=КонкретныйТип>.

Отличным примером ситуации, где эта техника полезна, является перегрузка операторов, при которой вы настраиваете поведение оператора (например, +) в определённых ситуациях.

Rust не позволяет создавать собственные операторы или перегружать произвольные операторы. Но вы можете перегружать операции и соответствующие типажи, перечисленные в std::ops, реализуя типажи, связанные с оператором. Например, в листинге 20-15 мы перегружаем оператор + для сложения двух экземпляров Point. Мы делаем это, реализуя типаж Add для структуры Point.

Filename: src/main.rs
use std::ops::Add;

#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Output = Point;

    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    assert_eq!(
        Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
        Point { x: 3, y: 3 }
    );
}
Listing 20-15: Реализация типажа Add для перегрузки оператора + для экземпляров Point

Метод add складывает значения x двух экземпляров Point и значения y двух экземпляров Point, чтобы создать новый Point. У типажа Add есть связанный тип с именем Output, который определяет тип, возвращаемый методом add.

Параметр типа по умолчанию в этом коде находится внутри типажа Add. Вот его определение:

#![allow(unused)]
fn main() {
trait Add<Rhs=Self> {
    type Output;

    fn add(self, rhs: Rhs) -> Self::Output;
}
}

Этот код должен выглядеть в целом знакомо: типаж с одним методом и связанным типом. Новая часть — Rhs=Self: этот синтаксис называется параметрами типа по умолчанию. Параметр обобщённого типа Rhs (сокращение от «right-hand side», правая сторона) определяет тип параметра rhs в методе add. Если мы не укажем конкретный тип для Rhs при реализации типажа Add, тип Rhs по умолчанию будет Self, который будет типом, для которого мы реализуем Add.

Когда мы реализовали Add для Point, мы использовали значение по умолчанию для Rhs, потому что хотели сложить два экземпляра Point. Давайте рассмотрим пример реализации типажа Add, где мы хотим настроить тип Rhs, а не использовать значение по умолчанию.

У нас есть две структуры, Millimeters и Meters, хранящие значения в разных единицах. Это тонкая обёртка существующего типа в другую структуру известна как паттерн newtype, который мы подробнее описываем в разделе «Использование паттерна newtype для реализации внешних типажей на внешних типах». Мы хотим сложить значения в миллиметрах со значениями в метрах и чтобы реализация Add выполнила преобразование правильно. Мы можем реализовать Add для Millimeters с Meters в качестве Rhs, как показано в листинге 20-16.

Filename: src/lib.rs
use std::ops::Add;

struct Millimeters(u32);
struct Meters(u32);

impl Add<Meters> for Millimeters {
    type Output = Millimeters;

    fn add(self, other: Meters) -> Millimeters {
        Millimeters(self.0 + (other.0 * 1000))
    }
}
Listing 20-16: Реализация типажа Add на Millimeters для сложения Millimeters и Meters

Чтобы сложить Millimeters и Meters, мы указываем impl Add<Meters>, чтобы задать значение параметра типа Rhs вместо использования значения по умолчанию Self.

Вы будете использовать параметры типа по умолчанию в двух основных случаях:

  1. Для расширения типа без нарушения существующего кода
  2. Для возможности настройки в конкретных случаях, которые большинству пользователей не нужны

Типаж Add из стандартной библиотеки — пример второй цели: обычно вы будете складывать два одинаковых типа, но типаж Add предоставляет возможность настройки за пределами этого. Использование параметра типа по умолчанию в определении типажа Add означает, что вам не нужно указывать дополнительный параметр в большинстве случаев. Другими словами, немного шаблонного кода для реализации не требуется, что облегчает использование типажа.

Первая цель похожа на вторую, но в обратном порядке: если вы хотите добавить параметр типа к существующему типажу, вы можете задать ему значение по умолчанию, чтобы расширить функциональность типажа без нарушения существующего кода реализации.

Различение методов с одинаковыми именами

Ничто в Rust не мешает типажу иметь метод с таким же именем, как у метода другого типажа, и Rust не мешает вам реализовать оба типажа на одном типе. Также возможно реализовать метод непосредственно на типе с таким же именем, как методы из типажей.

При вызове методов с одинаковыми именами вам нужно будет указать Rust, какой из них вы хотите использовать. Рассмотрим код в листинге 20-17, где мы определили два типажа, Pilot и Wizard, оба имеющие метод с именем fly. Затем мы реализуем оба типажа для типа Human, на котором уже реализован метод с именем fly. Каждый метод fly делает что-то разное.

Filename: src/main.rs
trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {}
Listing 20-17: Определены два типажа с методом fly и реализованы на типе Human, а также метод fly реализован на Human напрямую.

Когда мы вызываем fly на экземпляре Human, компилятор по умолчанию вызывает метод, реализованный непосредственно на типе, как показано в листинге 20-18.

Filename: src/main.rs
trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    person.fly();
}
Listing 20-18: Вызов fly на экземпляре Human

Запуск этого кода выведет *waving arms furiously*, показывая, что Rust вызвал метод fly, реализованный непосредственно на Human.

Чтобы вызвать методы fly из типажа Pilot или типажа Wizard, нам нужно использовать более явный синтаксис, чтобы указать, какой метод fly мы имеем в виду. Листинг 20-19 демонстрирует этот синтаксис.

Filename: src/main.rs
trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    Pilot::fly(&person);
    Wizard::fly(&person);
    person.fly();
}
Listing 20-19: Указание, какой метод fly из типажа мы хотим вызвать

Указание имени типажа перед именем метода проясняет Rust, какую реализацию fly мы хотим вызвать. Мы также могли бы написать Human::fly(&person), что эквивалентно person.fly(), которое мы использовали в листинге 20-19, но это немного длиннее писать, если нам не нужно различать.

Запуск этого кода выводит следующее:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.46s
     Running `target/debug/traits-example`
This is your captain speaking.
Up!
*waving arms furiously*

Поскольку метод fly принимает параметр self, если бы у нас было два типа, оба реализующих один типаж, Rust мог бы определить, какую реализацию типажа использовать, на основе типа self.

Однако связанные функции, которые не являются методами, не имеют параметра self. Когда есть несколько типов или типажей, определяющих не-метод функции с одинаковым именем функции, Rust не всегда знает, какой тип вы имеете в виду, если не используете полностью квалифицированный синтаксис. Например, в листинге 20-20 мы создаём типаж для приюта для животных, который хочет назвать всех щенков Spot. Мы создаём типаж Animal со связанной не-метод функцией baby_name. Типаж Animal реализован для структуры Dog, на которой также предоставляем связанную не-метод функцию baby_name напрямую.

Filename: src/main.rs
trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Dog::baby_name());
}
Listing 20-20: Типаж со связанной функцией и тип со связанной функцией с тем же именем, который также реализует типаж

Мы реализуем код для названия всех щенков Spot в связанной функции baby_name, определённой на Dog. Тип Dog также реализует типаж Animal, который описывает характеристики, общие для всех животных. Щенков называют puppies, и это выражено в реализации типажа Animal на Dog в функции baby_name, связанной с типажом Animal.

В main мы вызываем функцию Dog::baby_name, которая вызывает связанную функцию, определённую на Dog напрямую. Этот код выводит следующее:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.54s
     Running `target/debug/traits-example`
A baby dog is called a Spot

Этот вывод не тот, который мы хотели. Мы хотим вызвать функцию baby_name, которая является частью типажа Animal, который мы реализовали на Dog, чтобы код вывел A baby dog is called a puppy. Техника указания имени типажа, которую мы использовали в листинге 20-19, здесь не помогает; если мы изменим main на код из листинга 20-21, мы получим ошибку компиляции.

Filename: src/main.rs
trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Animal::baby_name());
}
Listing 20-21: Попытка вызвать функцию baby_name из типажа Animal, но Rust не знает, какую реализацию использовать

Поскольку Animal::baby_name не имеет параметра self и могут быть другие типы, реализующие типаж Animal, Rust не может определить, какую реализацию Animal::baby_name мы хотим. Мы получим эту ошибку компилятора:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0790]: cannot call associated function on trait without specifying the corresponding `impl` type
  --> src/main.rs:20:43
   |
2  |     fn baby_name() -> String;
   |     ------------------------- `Animal::baby_name` defined here
...
20 |     println!("A baby dog is called a {}", Animal::baby_name());
   |                                           ^^^^^^^^^^^^^^^^^^^ cannot call associated function of trait
   |
help: use the fully-qualified path to the only available implementation
   |
20 |     println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
   |                                           +++++++       +

For more information about this error, try `rustc --explain E0790`.
error: could not compile `traits-example` (bin "traits-example") due to 1 previous error

Чтобы устранить неоднозначность и указать Rust, что мы хотим использовать реализацию Animal для Dog, а не реализацию Animal для какого-то другого типа, нам нужно использовать полностью квалифицированный синтаксис. Листинг 20-22 демонстрирует, как использовать полностью квалифицированный синтаксис.

Filename: src/main.rs
trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
}
Listing 20-22: Использование полностью квалифицированного синтаксиса для указания, что мы хотим вызвать функцию baby_name из типажа Animal как реализованную на Dog

Мы предоставляем Rust аннотацию типа внутри угловых скобок, которая указывает, что мы хотим вызвать метод baby_name из типажа Animal как реализованный на Dog, говоря, что мы хотим рассматривать тип Dog как Animal для этого вызова функции. Этот код теперь выведет то, что мы хотим:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/traits-example`
A baby dog is called a puppy

В общем случае полностью квалифицированный синтаксис определяется следующим образом:

<Type as Trait>::function(receiver_if_method, next_arg, ...);

Для связанных функций, которые не являются методами, не было бы receiver: там был бы только список других аргументов. Вы могли бы использовать полностью квалифицированный синтаксис везде, где вызываете функции или методы. Однако вам разрешено опускать любую часть этого синтаксиса, которую Rust может определить из другой информации в программе. Вам нужно использовать этот более многословный синтаксис только в случаях, когда есть несколько реализаций, использующих одно и то же имя, и Rust нужна помощь, чтобы определить, какую реализацию вы хотите вызвать.

Использование родительских типажей

Иногда вы можете написать определение типажа, которое зависит от другого типажа: чтобы тип реализовал первый типаж, вы хотите потребовать, чтобы этот тип также реализовал второй типаж. Вы бы сделали это, чтобы ваше определение типажа могло использовать связанные элементы второго типажа. Типаж, от которого зависит ваше определение типажа, называется родительским типажем вашего типажа.

Например, предположим, мы хотим сделать типаж OutlinePrint с методом outline_print, который будет печатать заданное значение, отформатированное так, чтобы оно было обрамлено звёздочками. То есть, учитывая структуру Point, которая реализует стандартный типаж библиотеки Display для результата (x, y), когда мы вызываем outline_print на экземпляре Point с 1 для x и 3 для y, он должен вывести следующее:

**********
*        *
* (1, 3) *
*        *
**********

В реализации метода outline_print мы хотим использовать функциональность типажа Display. Поэтому нам нужно указать, что типаж OutlinePrint будет работать только для типов, которые также реализуют Display и предоставляют функциональность, необходимую OutlinePrint. Мы можем сделать это в определении типажа, указав OutlinePrint: Display. Эта техника похожа на добавление ограничения типажа к типажу. Листинг 20-23 показывает реализацию типажа OutlinePrint.

Filename: src/main.rs
use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

fn main() {}
Listing 20-23: Реализация типажа OutlinePrint, требующего функциональности из Display

Поскольку мы указали, что OutlinePrint требует типаж Display, мы можем использовать функцию to_string, которая автоматически реализуется для любого типа, реализующего Display. Если бы мы попытались использовать to_string без добавления двоеточия и указания типажа Display после имени типажа, мы получили бы ошибку, что метод с именем to_string не найден для типа &Self в текущей области видимости.

Давайте посмотрим, что происходит, когда мы пытаемся реализовать OutlinePrint для типа, который не реализует Display, такого как структура Point:

Filename: src/main.rs
use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

Мы получаем ошибку, что Display требуется, но не реализован:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:20:23
   |
20 | impl OutlinePrint for Point {}
   |                       ^^^^^ `Point` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Point`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `OutlinePrint`
  --> src/main.rs:3:21
   |
3  | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint`

error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:24:7
   |
24 |     p.outline_print();
   |       ^^^^^^^^^^^^^ `Point` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Point`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `OutlinePrint::outline_print`
  --> src/main.rs:3:21
   |
3  | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint::outline_print`
4  |     fn outline_print(&self) {
   |        ------------- required by a bound in this associated function

For more information about this error, try `rustc --explain E0277`.
error: could not compile `traits-example` (bin "traits-example") due to 2 previous errors

Чтобы исправить это, мы реализуем Display на Point и удовлетворим ограничение, которое требует OutlinePrint, вот так:

Filename: src/main.rs
trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

use std::fmt;

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

Затем реализация типажа OutlinePrint на Point будет успешно компилироваться, и мы сможем вызывать outline_print на экземпляре Point для отображения его в рамке из звёздочек.

Использование паттерна newtype для реализации внешних типажей на внешних типах

В разделе «Реализация типажа на типе» в главе 10 мы упомянули правило сирот, которое гласит, что мы можем реализовать типаж на типе только если либо типаж, либо тип, или оба, являются локальными для нашего крейта. Возможно обойти это ограничение, используя паттерн newtype, который предполагает создание нового типа в кортежной структуре. (Мы рассмотрели кортежные структуры в разделе «Использование кортежных структур без именованных полей для создания разных типов» в главе 5.) Кортежная структура будет иметь одно поле и будет тонкой обёрткой вокруг типа, для которого мы хотим реализовать типаж. Затем обёртывающий тип локальн для нашего крейта, и мы можем реализовать типаж на обёртке. Newtype — это термин, происходящий из языка программирования Haskell. Нет накладных расходов во время выполнения при использовании этого паттерна, и обёртывающий тип устраняется во время компиляции.

В качестве примера предположим, мы хотим реализовать Display на Vec<T>, что правило сирот не позволяет нам сделать напрямую, потому что типаж Display и тип Vec<T> определены вне нашего крейта. Мы можем сделать структуру Wrapper, которая содержит экземпляр Vec<T>; затем мы можем реализовать Display на Wrapper и использовать значение Vec<T>, как показано в листинге 20-24.

Filename: src/main.rs
use std::fmt;

struct Wrapper(Vec<String>);

impl fmt::Display for Wrapper {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

fn main() {
    let w = Wrapper(vec![String::from("hello"), String::from("world")]);
    println!("w = {w}");
}
Listing 20-24: Создание типа Wrapper вокруг Vec<String> для реализации Display

Реализация Display использует self.0 для доступа к внутреннему Vec<T>, потому что Wrapper — это кортежная структура, и Vec<T> — это элемент с индексом 0 в кортеже. Затем мы можем использовать функциональность типажа Display на Wrapper.

Недостаток использования этой техники в том, что Wrapper — это новый тип, поэтому у него нет методов значения, которое он содержит. Нам пришлось бы реализовать все методы Vec<T> непосредственно на Wrapper так, чтобы методы делегировали self.0, что позволило бы нам обращаться с Wrapper точно как с Vec<T>. Если бы мы хотели, чтобы новый тип имел каждый метод, который есть у внутреннего типа, реализация типажа Deref на Wrapper для возврата внутреннего типа была бы решением (мы обсуждали реализацию типажа Deref в разделе «Обращение с умными указателями как с обычными ссылками с помощью типажа Deref» в главе 15). Если бы мы не хотели, чтобы тип Wrapper имел все методы внутреннего типа — например, чтобы ограничить поведение типа Wrapper — нам пришлось бы реализовать вручную только те методы, которые мы хотим.

Этот паттерн newtype также полезен, даже когда типажи не вовлечены. Давайте сменим фокус и посмотрим на некоторые продвинутые способы взаимодействия с системой типов Rust.

Продвинутые типы

Система типов Rust обладает некоторыми возможностями, о которых мы упоминали ранее, но ещё не обсуждали. Мы начнём с рассмотрения новых типов (newtypes) в целом, чтобы понять, почему они полезны как типы. Затем перейдём к псевдонимам типов — функции, похожей на новые типы, но с немного другой семантикой. Мы также обсудим тип ! и динамически размещённые типы.

Использование паттерна нового типа для безопасности типов и абстракции

Этот раздел предполагает, что вы ознакомились с предыдущим разделом «Использование паттерна нового типа для реализации внешних типажей на внешних типах». Паттерн нового типа также полезен для задач, выходящих за рамки уже рассмотренных, включая статическое обеспечение того, чтобы значения никогда не путались, и указание единиц измерения значения. Вы видели пример использования новых типов для указания единиц в Листинге 20-16: вспомните, что структуры Millimeters и Meters оборачивали значения u32 в новый тип. Если бы мы написали функцию с параметром типа Millimeters, мы не смогли бы скомпилировать программу, которая случайно пытается вызвать эту функцию со значением типа Meters или простым u32.

Мы также можем использовать паттерн нового типа для абстрагирования некоторых деталей реализации типа: новый тип может предоставлять публичный API, отличающийся от API приватного внутреннего типа.

Новые типы также могут скрывать внутреннюю реализацию. Например, мы могли бы предоставить тип People для обёртки HashMap<i32, String>, который хранит идентификатор человека, связанный с его именем. Код, использующий People, взаимодействовал бы только с публичным API, который мы предоставляем, например, методом для добавления строки имени в коллекцию People; этому коду не нужно было бы знать, что мы внутренне назначаем идентификатор i32 именам. Паттерн нового типа — это лёгкий способ достичь инкапсуляции для скрытия деталей реализации, о чём мы говорили в разделе «Инкапсуляция, скрывающая детали реализации» в главе 18.

Создание синонимов типов с помощью псевдонимов типов

Rust предоставляет возможность объявлять псевдоним типа, чтобы дать существующему типу другое имя. Для этого используется ключевое слово type. Например, мы можем создать псевдоним Kilometers для i32 следующим образом:

fn main() {
    type Kilometers = i32;

    let x: i32 = 5;
    let y: Kilometers = 5;

    println!("x + y = {}", x + y);
}

Теперь псевдоним Kilometers является синонимом для i32; в отличие от типов Millimeters и Meters, которые мы создали в Листинге 20-16, Kilometers не является отдельным новым типом. Значения, имеющие тип Kilometers, будут обрабатываться так же, как значения типа i32:

fn main() {
    type Kilometers = i32;

    let x: i32 = 5;
    let y: Kilometers = 5;

    println!("x + y = {}", x + y);
}

Поскольку Kilometers и i32 — это один и тот же тип, мы можем складывать значения обоих типов и передавать значения Kilometers в функции, которые принимают параметры i32. Однако, используя этот метод, мы не получаем преимуществ проверки типов, которые даёт паттерн нового типа, рассмотренный ранее. Другими словами, если мы где-то перепутаем значения Kilometers и i32, компилятор не выдаст нам ошибку.

Основной вариант использования псевдонимов типов — уменьшение повторений. Например, у нас может быть громоздкий тип вроде этого:

Box<dyn Fn() + Send + 'static>

Писать этот громоздкий тип в сигнатурах функций и в виде аннотаций типов по всему коду может быть утомительно и чревато ошибками. Представьте проект, полный кода, подобного Листингу 20-25.

fn main() {
    let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));

    fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
        // --snip--
    }

    fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
        // --snip--
        Box::new(|| ())
    }
}
Listing 20-25: Использование длинного типа во многих местах

Псевдоним типа делает этот код более управляемым, уменьшая повторения. В Листинге 20-26 мы ввели псевдоним Thunk для многословного типа и можем заменить все использования типа на более короткий псевдоним Thunk.

fn main() {
    type Thunk = Box<dyn Fn() + Send + 'static>;

    let f: Thunk = Box::new(|| println!("hi"));

    fn takes_long_type(f: Thunk) {
        // --snip--
    }

    fn returns_long_type() -> Thunk {
        // --snip--
        Box::new(|| ())
    }
}
Listing 20-26: Введение псевдонима типа Thunk для уменьшения повторений

Этот код гораздо легче читать и писать! Выбор осмысленного имени для псевдонима типа также может помочь передать ваше намерение (thunk — это слово для кода, который будет вычислен позже, поэтому это подходящее имя для замыкания, которое сохраняется).

Псевдонимы типов также часто используются с типом Result<T, E> для уменьшения повторений. Рассмотрим модуль std::io в стандартной библиотеке. Операции ввода-вывода часто возвращают Result<T, E> для обработки ситуаций, когда операции не удаются. В этой библиотеке есть структура std::io::Error, представляющая все возможные ошибки ввода-вывода. Многие функции в std::io будут возвращать Result<T, E>, где E — это std::io::Error, например, эти функции в типаже Write:

use std::fmt;
use std::io::Error;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
    fn flush(&mut self) -> Result<(), Error>;

    fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}

Result<..., Error> повторяется много раз. Поэтому std::io имеет это объявление псевдонима типа:

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

Поскольку это объявление находится в модуле std::io, мы можем использовать полностью квалифицированный псевдоним std::io::Result<T>; то есть Result<T, E> с E, заполненным как std::io::Error. Сигнатуры функций типажа Write в итоге выглядят так:

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

Псевдоним типа помогает в двух аспектах: он облегчает написание кода и даёт нам единообразный интерфейс во всём std::io. Поскольку это псевдоним, это просто ещё один Result<T, E>, что означает, что мы можем использовать с ним любые методы, работающие с Result<T, E>, а также специальный синтаксис, такой как оператор ?.

Тип «никогда», который никогда не возвращается

Rust имеет специальный тип с именем !, известный в терминах теории типов как пустой тип, потому что у него нет значений. Мы предпочитаем называть его типом «никогда», потому что он занимает место возвращаемого типа, когда функция никогда не вернётся. Вот пример:

fn bar() -> ! {
    // --snip--
    panic!();
}

Этот код читается как «функция bar возвращает никогда». Функции, которые возвращают никогда, называются расходящимися функциями. Мы не можем создавать значения типа !, поэтому bar никогда не сможет вернуться.

Но какова польза от типа, для которого вы никогда не можете создавать значения? Вспомните код из Листинга 2-5, часть игры по угадыванию чисел; мы воспроизвели его немного здесь в Листинге 20-27.

use std::cmp::Ordering;
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}");

    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;
            }
        }
    }
}
Listing 20-27: match с ветвью, заканчивающейся на continue

В то время мы пропустили некоторые детали в этом коде. В разделе «Оператор управления потоком match» в главе 6 мы обсудили, что все ветви match должны возвращать один и тот же тип. Так, например, следующий код не работает:

fn main() {
    let guess = "3";
    let guess = match guess.trim().parse() {
        Ok(_) => 5,
        Err(_) => "hello",
    };
}

Тип guess в этом коде должен был бы быть целым числом и строкой, а Rust требует, чтобы guess имел только один тип. Так что же возвращает continue? Как нам разрешили возвращать u32 из одной ветви и иметь другую ветвь, заканчивающуюся на continue в Листинге 20-27?

Как вы могли догадаться, continue имеет значение !. То есть, когда Rust вычисляет тип guess, он смотрит на обе ветви match, первая со значением u32, а вторая со значением !. Поскольку ! никогда не может иметь значение, Rust решает, что тип guess — это u32.

Формальным способом описания этого поведения является то, что выражения типа ! могут быть приведены к любому другому типу. Нам разрешено заканчивать эту ветвь match на continue, потому что continue не возвращает значение; вместо этого оно передаёт управление обратно в начало цикла, поэтому в случае Err мы никогда не присваиваем значение guess.

Тип «никогда» также полезен с макросом panic!. Вспомните функцию unwrap, которую мы вызываем для значений Option<T>, чтобы получить значение или вызвать панику с этим определением:

enum Option<T> {
    Some(T),
    None,
}

use crate::Option::*;

impl<T> Option<T> {
    pub fn unwrap(self) -> T {
        match self {
            Some(val) => val,
            None => panic!("called `Option::unwrap()` on a `None` value"),
        }
    }
}

В этом коде происходит то же самое, что и в match в Листинге 20-27: Rust видит, что val имеет тип T, а panic! имеет тип !, поэтому результат всего выражения match — это T. Этот код работает, потому что panic! не производит значение; он завершает программу. В случае None мы не будем возвращать значение из unwrap, поэтому этот код корректен.

Последнее выражение, имеющее тип !, — это loop:

fn main() {
    print!("forever ");

    loop {
        print!("and ever ");
    }
}

Здесь цикл никогда не заканчивается, поэтому ! — это значение выражения. Однако это не было бы верно, если бы мы включили break, потому что цикл завершился бы, когда достиг бы break.

Динамически размещённые типы и типаж Sized

Rust должен знать определённые детали о своих типах, например, сколько памяти выделить для значения particular типа. Это оставляет один уголок его системы типов немного запутанным на первый взгляд: концепцию динамически размещённых типов. Иногда называемых DST или неразмерёнными типами, эти типы позволяют нам писать код, используя значения, размер которых мы можем узнать только во время выполнения.

Давайте углубимся в детали динамически размещённого типа под названием str, который мы использовали на протяжении всей книги. Правильно, не &str, а str сам по себе, является DST. Мы не можем знать, насколько длинна строка, до времени выполнения, что означает, что мы не можем создать переменную типа str и не можем принимать аргумент типа str. Рассмотрим следующий код, который не работает:

fn main() {
    let s1: str = "Hello there!";
    let s2: str = "How's it going?";
}

Rust должен знать, сколько памяти выделить для любого значения particular типа, и все значения типа должны использовать одинаковое количество памяти. Если бы Rust позволил нам написать этот код, эти два значения str должны были бы занимать одинаковое количество места. Но они имеют разную длину: s1 требует 12 байт хранения, а s2 — 15. Вот почему невозможно создать переменную, содержащую динамически размещённый тип.

Так что же мы делаем? В этом случае вы уже знаете ответ: мы делаем типы s1 и s2 &str, а не str. Вспомните из раздела «Срезы строк» в главе 4, что структура среза просто хранит начальную позицию и длину среза. Так что, хотя &T — это одно значение, хранящее адрес памяти, где находится T, &str — это два значения: адрес str и его длина. Таким образом, мы всегда можем знать размер значения &str на этапе компиляции: это вдвое больше длины usize. То есть мы всегда знаем размер &str, независимо от того, насколько длинна строка, на которую он ссылается. Вообще, это способ, которым динамически размещённые типы используются в Rust: они имеют дополнительный бит метаданных, который хранит размер динамической информации. Золотое правило динамически размещённых типов заключается в том, что мы всегда должны помещать значения динамически размещённых типов за указатель некоторого рода.

Мы можем комбинировать str со всеми видами указателей: например, Box<str> или Rc<str>. На самом деле, вы видели это раньше, но с другим динамически размещённым типом: типажами. Каждый типаж является динамически размещённым типом, на который мы можем ссылаться, используя имя типажа. В разделе «Использование объектов типажей, которые позволяют значениям разных типов» в главе 18 мы упомянули, что для использования типажей как объектов типажей мы должны помещать их за указатель, такой как &dyn Trait или Box<dyn Trait> (Rc<dyn Trait> тоже сработает).

Для работы с DST Rust предоставляет типаж Sized для определения, известен ли размер типа на этапе компиляции. Этот типаж автоматически реализуется для всего, чей размер известен на этапе компиляции. Кроме того, Rust неявно добавляет ограничение на Sized к каждой универсальной функции. То есть определение универсальной функции вроде этого:

fn generic<T>(t: T) {
    // --snip--
}

фактически обрабатывается так, как если бы мы написали это:

fn generic<T: Sized>(t: T) {
    // --snip--
}

По умолчанию универсальные функции будут работать только с типами, имеющими известный размер на этапе компиляции. Однако вы можете использовать следующий специальный синтаксис, чтобы ослабить это ограничение:

fn generic<T: ?Sized>(t: &T) {
    // --snip--
}

Ограничение типажа на ?Sized означает «T может быть Sized, а может и не быть», и эта нотация переопределяет значение по умолчанию, что универсальные типы должны иметь известный размер на этапе компиляции. Синтаксис ?Trait с этим значением доступен только для Sized, а не для любых других типажей.

Также обратите внимание, что мы изменили тип параметра t с T на &T. Поскольку тип может не быть Sized, нам нужно использовать его за каким-то указателем. В этом случае мы выбрали ссылку.

Далее мы поговорим о функциях и замыканиях!

Продвинутые функции и замыкания

В этом разделе рассматриваются некоторые продвинутые возможности, связанные с функциями и замыканиями, включая указатели на функции и возврат замыканий.

Указатели на функции

Мы уже говорили о том, как передавать замыкания в функции; вы также можете передавать в функции обычные функции! Этот приём полезен, когда вы хотите передать уже определённую функцию, а не создавать новое замыкание. Функции приводятся к типу fn (с маленькой буквы), чтобы не путать с типажом замыкания Fn. Тип fn называется указателем на функцию. Передача функций с помощью указателей на функции позволит вам использовать функции в качестве аргументов для других функций.

Синтаксис указания того, что параметр является указателем на функцию, похож на синтаксис для замыканий, как показано в Листинге 20-28, где мы определили функцию add_one, которая добавляет 1 к своему параметру. Функция do_twice принимает два параметра: указатель на функцию любой функции, которая принимает параметр i32 и возвращает i32, а также одно значение i32. Функция do_twice вызывает функцию f дважды, передавая ей значение arg, а затем складывает результаты двух вызовов. Функция main вызывает do_twice с аргументами add_one и 5.

Filename: src/main.rs
fn add_one(x: i32) -> i32 {
    x + 1
}

fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
    f(arg) + f(arg)
}

fn main() {
    let answer = do_twice(add_one, 5);

    println!("The answer is: {answer}");
}
Listing 20-28: Использование типа fn для принятия указателя на функцию в качестве аргумента

Этот код выводит The answer is: 12. Мы указываем, что параметр f в do_twice имеет тип fn, который принимает один параметр типа i32 и возвращает i32. Затем мы можем вызвать f в теле do_twice. В main мы можем передать имя функции add_one в качестве первого аргумента в do_twice.

В отличие от замыканий, fn — это тип, а не типаж, поэтому мы указываем fn как тип параметра напрямую, а не объявляем обобщённый параметр типа с одним из типажей Fn.

Указатели на функции реализуют все три типажа замыканий (Fn, FnMut и FnOnce), что означает, что вы всегда можете передать указатель на функцию в качестве аргумента для функции, ожидающей замыкание. Лучше всего писать функции, используя обобщённый тип и один из типажей замыканий, чтобы ваши функции могли принимать как функции, так и замыкания.

Тем не менее, примером, где вы хотите принимать только fn, а не замыкания, является взаимодействие с внешним кодом, в котором нет замыканий: функции C могут принимать функции в качестве аргументов, но в C нет замыканий.

В качестве примера, где можно использовать либо замыкание, определённое встроенным способом, либо именованную функцию, рассмотрим использование метода map, предоставляемого типажом Iterator в стандартной библиотеке. Чтобы использовать метод map для преобразования вектора чисел в вектор строк, мы могли бы использовать замыкание, как в Листинге 20-29.

fn main() {
    let list_of_numbers = vec![1, 2, 3];
    let list_of_strings: Vec<String> =
        list_of_numbers.iter().map(|i| i.to_string()).collect();
}
Listing 20-29: Использование замыкания с методом map для преобразования чисел в строки

Или мы могли бы указать функцию в качестве аргумента для map вместо замыкания. Листинг 20-30 показывает, как это выглядело бы.

fn main() {
    let list_of_numbers = vec![1, 2, 3];
    let list_of_strings: Vec<String> =
        list_of_numbers.iter().map(ToString::to_string).collect();
}
Listing 20-30: Использование метода String::to_string для преобразования чисел в строки

Обратите внимание, что мы должны использовать полностью квалифицированный синтаксис, о котором мы говорили в разделе «Продвинутые типажи», потому что существует несколько функций с именем to_string.

Здесь мы используем функцию to_string, определённую в типаже ToString, который стандартная библиотека реализовала для любого типа, реализующего Display.

Вспомните из раздела «Значения перечисления» в главе 6, что имя каждого варианта перечисления, которое мы определяем, также становится функцией-инициализатором. Мы можем использовать эти функции-инициализаторы как указатели на функции, реализующие типажи замыканий, что означает, что мы можем указывать функции-инициализаторы в качестве аргументов для методов, принимающих замыкания, как показано в Листинге 20-31.

fn main() {
    enum Status {
        Value(u32),
        Stop,
    }

    let list_of_statuses: Vec<Status> = (0u32..20).map(Status::Value).collect();
}
Listing 20-31: Использование инициализатора перечисления с методом map для создания экземпляра Status из чисел

Здесь мы создаём экземпляры Status::Value, используя каждое значение u32 в диапазоне, на котором вызывается map, с помощью функции-инициализатора Status::Value. Некоторым нравится этот стиль, а некоторым — использовать замыкания. Они компилируются в один и тот же код, поэтому используйте тот стиль, который понятнее вам.

Возврат замыканий

Замыкания представлены типажами, что означает, что вы не можете возвращать замыкания напрямую. В большинстве случаев, когда вы могли бы захотеть вернуть типаж, вы можете вместо этого использовать конкретный тип, реализующий этот типаж, в качестве возвращаемого значения функции. Однако с замыканиями это обычно невозможно сделать, потому что у них нет конкретного возвращаемого типа. Вам не разрешено использовать указатель на функцию fn в качестве возвращаемого типа, если замыкание захватывает какие-либо значения из своей области видимости, например.

Вместо этого вы обычно будете использовать синтаксис impl Trait, который мы изучили в главе 10. Вы можете вернуть любой тип функции, используя Fn, FnOnce и FnMut. Например, код в Листинге 20-32 будет работать perfectly.

#![allow(unused)]
fn main() {
fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
}
}
Listing 20-32: Возврат замыкания из функции с использованием синтаксиса impl Trait

Однако, как мы отметили в разделе «Вывод типов замыканий и аннотации» в главе 13, каждое замыкание также является собственным отдельным типом. Если вам нужно работать с несколькими функциями, имеющими одинаковую сигнатуру, но разные реализации, вам придётся использовать объект типажа для них. Посмотрите, что произойдёт, если вы напишете код, подобный показанному в Листинге 20-33.

Filename: src/main.rs
fn main() {
    let handlers = vec![returns_closure(), returns_initialized_closure(123)];
    for handler in handlers {
        let output = handler(5);
        println!("{output}");
    }
}

fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
}

fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
    move |x| x + init
}
Listing 20-33: Создание Vec<T> из замыканий, определённых функциями, которые возвращают impl Fn

Здесь у нас есть две функции, returns_closure и returns_initialized_closure, которые обе возвращают impl Fn(i32) -> i32. Обратите внимание, что замыкания, которые они возвращают, разные, даже если они реализуют один и тот же типаж. Если мы попытаемся скомпилировать это, Rust сообщит нам, что это не сработает:

$ cargo build
   Compiling functions-example v0.1.0 (file:///projects/functions-example)
error[E0308]: mismatched types
  --> src/main.rs:2:44
   |
2  |     let handlers = vec![returns_closure(), returns_initialized_closure(123)];
   |                                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected opaque type, found a different opaque type
...
9  | fn returns_closure() -> impl Fn(i32) -> i32 {
   |                         ------------------- the expected opaque type
...
13 | fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
   |                                              ------------------- the found opaque type
   |
   = note: expected opaque type `impl Fn(i32) -> i32` (opaque type at <src/main.rs:9:25>)
              found opaque type `impl Fn(i32) -> i32` (opaque type at <src/main.rs:13:46>)
   = note: distinct uses of `impl Trait` result in different opaque types

For more information about this error, try `rustc --explain E0308`.
error: could not compile `functions-example` (bin "functions-example") due to 1 previous error

Сообщение об ошибке говорит нам, что каждый раз, когда мы возвращаем impl Trait, Rust создаёт уникальный непрозрачный тип — тип, в детали которого мы не можем заглянуть, который Rust конструирует для нас. Поэтому, даже though эти функции обе возвращают замыкания, реализующие один и тот же типаж, Fn(i32) -> i32, непрозрачные типы, которые Rust генерирует для каждого из них, различны. (Это похоже на то, как Rust производит разные конкретные типы для отдельных асинхронных блоков, даже когда у них одинаковый тип возвращаемого значения, как мы видели в разделе «Работа с любым количеством фьючерсов» в главе 17. Мы уже видели решение этой проблемы несколько раз: мы можем использовать объект типажа, как в Листинге 20-34.

fn main() {
    let handlers = vec![returns_closure(), returns_initialized_closure(123)];
    for handler in handlers {
        let output = handler(5);
        println!("{output}");
    }
}

fn returns_closure() -> Box<dyn Fn(i32) -> i32> {
    Box::new(|x| x + 1)
}

fn returns_initialized_closure(init: i32) -> Box<dyn Fn(i32) -> i32> {
    Box::new(move |x| x + init)
}
Listing 20-34: Создание Vec<T> из замыканий, определённых функциями, которые возвращают Box<dyn Fn>, чтобы они имели один и тот же тип

Этот код будет компилироваться без проблем. Для получения дополнительной информации об объектах типажей обратитесь к разделу «Использование объектов типажей, которые позволяют иметь значения разных типов» в главе 18.

Далее рассмотрим макросы!

Макросы

Мы использовали макросы вроде println! на протяжении всей этой книги, но мы не полностью изучили, что такое макрос и как он работает. Термин макрос относится к семейству возможностей в Rust: декларативные макросы с macro_rules! и три вида процедурных макросов:

  • Пользовательские макросы #[derive], которые указывают код, добавляемый с атрибутом derive, используемым на структурах и перечислениях
  • Атрибутоподобные макросы, которые определяют пользовательские атрибуты, применимые к любому элементу
  • Функциеподобные макросы, которые выглядят как вызовы функций, но работают с токенами, указанными в качестве их аргумента

Мы поговорим о каждом из них по очереди, но сначала давайте посмотрим, зачем нам вообще нужны макросы, когда у нас уже есть функции.

Разница между макросами и функциями

По сути, макросы — это способ написания кода, который пишет другой код, что известно как метапрограммирование. В Приложении C мы обсуждаем атрибут derive, который генерирует реализацию различных трейтов для вас. Мы также использовали макросы println! и vec! на протяжении всей книги. Все эти макросы раскрываются, чтобы произвести больше кода, чем код, который вы написали вручную.

Метапрограммирование полезно для уменьшения количества кода, который вам нужно писать и поддерживать, что также является одной из ролей функций. Однако макросы имеют некоторые дополнительные возможности, которых нет у функций.

Сигнатура функции должна объявлять количество и тип параметров, которые имеет функция. Макросы, с другой стороны, могут принимать переменное количество параметров: мы можем вызвать println!("hello") с одним аргументом или println!("hello {}", name) с двумя аргументами. Кроме того, макросы раскрываются до того, как компилятор интерпретирует значение кода, поэтому макрос может, например, реализовать трейт для данного типа. Функция не может этого сделать, потому что она вызывается во время выполнения, а трейт должен быть реализован во время компиляции.

Недостаток реализации макроса вместо функции заключается в том, что определения макросов более сложны, чем определения функций, потому что вы пишете код Rust, который пишет код Rust. Из-за этого косвенного обращения определения макросов обычно труднее читать, понимать и поддерживать, чем определения функций.

Еще одно важное различие между макросами и функциями заключается в том, что вы должны определить макросы или ввести их в область видимости до того, как вы вызовете их в файле, в отличие от функций, которые вы можете определить где угодно и вызвать где угодно.

Декларативные макросы с macro_rules! для общего метапрограммирования

Наиболее широко используемая форма макросов в Rust — это декларативный макрос. Они также иногда называются “макросами по примеру”, “макросами macro_rules!” или просто “макросами”. По своей сути декларативные макросы позволяют вам писать что-то похожее на выражение match в Rust. Как обсуждалось в Главе 6, выражения match — это управляющие структуры, которые принимают выражение, сравнивают результирующее значение выражения с шаблонами, а затем выполняют код, связанный с совпадающим шаблоном. Макросы также сравнивают значение с шаблонами, которые связаны с определенным кодом: в этой ситуации значением является буквальный исходный код Rust, переданный макросу; шаблоны сравниваются со структурой этого исходного кода; и код, связанный с каждым шаблоном, при совпадении заменяет код, переданный макросу. Все это происходит во время компиляции.

Чтобы определить макрос, вы используете конструкцию macro_rules!. Давайте изучим, как использовать macro_rules!, посмотрев, как определен макрос vec!. Глава 8 рассказывала, как мы можем использовать макрос vec! для создания нового вектора с определенными значениями. Например, следующий макрос создает новый вектор, содержащий три целых числа:

#![allow(unused)]
fn main() {
let v: Vec<u32> = vec![1, 2, 3];
}

Мы также могли бы использовать макрос vec! для создания вектора из двух целых чисел или вектора из пяти строковых срезов. Мы не смогли бы использовать функцию для того же самого, потому что мы не знали бы количество или тип значений заранее.

Листинг 20-35 показывает немного упрощенное определение макроса vec!.

Filename: src/lib.rs
#[macro_export]
macro_rules! vec {
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}
Listing 20-35: Упрощенная версия определения макроса vec!

Примечание: Фактическое определение макроса vec! в стандартной библиотеке включает код для предварительного выделения правильного объема памяти заранее. Этот код является оптимизацией, которую мы не включаем сюда, чтобы сделать пример проще.

Аннотация #[macro_export] указывает, что этот макрос должен быть сделан доступным всякий раз, когда крейт, в котором определен макрос, вводится в область видимости. Без этой аннотации макрос не может быть введен в область видимости.

Затем мы начинаем определение макроса с macro_rules! и имени макроса, который мы определяем, без восклицательного знака. Имя, в данном случае vec, сопровождается фигурными скобками, обозначающими тело определения макроса.

Структура в теле vec! похожа на структуру выражения match. Здесь у нас есть одна ветвь с шаблоном ( $( $x:expr ),* ), за которым следует => и блок кода, связанный с этим шаблоном. Если шаблон совпадает, связанный блок кода будет выдан. Учитывая, что это единственный шаблон в этом макросе, есть только один допустимый способ совпадения; любой другой шаблон приведет к ошибке. Более сложные макросы будут иметь более одной ветви.

Допустимый синтаксис шаблонов в определениях макросов отличается от синтаксиса шаблонов, рассмотренного в Главе 19, потому что шаблоны макросов сопоставляются со структурой кода Rust, а не со значениями. Давайте разберем, что означают части шаблона в Листинге 20-29; полный синтаксис шаблонов макросов см. в Справочнике Rust.

Сначала мы используем набор круглых скобок, чтобы охватить весь шаблон. Мы используем знак доллара ($), чтобы объявить переменную в системе макросов, которая будет содержать код Rust, соответствующий шаблону. Знак доллара ясно показывает, что это переменная макроса, а не обычная переменная Rust. Далее идет набор круглых скобок, которые захватывают значения, соответствующие шаблону внутри скобок, для использования в коде замены. Внутри $() находится $x:expr, который соответствует любому выражению Rust и дает выражению имя $x.

Запятая, следующая за $(), указывает, что символ-разделитель в виде буквальной запятой должен появляться между каждым экземпляром кода, который соответствует коду внутри $(). * указывает, что шаблон соответствует нулю или более тому, что предшествует *.

Когда мы вызываем этот макрос с vec![1, 2, 3];, шаблон $x совпадает три раза с тремя выражениями 1, 2 и 3.

Теперь давайте посмотрим на шаблон в теле кода, связанного с этой ветвью: temp_vec.push() внутри $()* генерируется для каждой части, которая соответствует $() в шаблоне ноль или более раз в зависимости от того, сколько раз совпадает шаблон. $x заменяется каждым совпавшим выражением. Когда мы вызываем этот макрос с vec![1, 2, 3];, сгенерированный код, который заменяет этот вызов макроса, будет следующим:

{
    let mut temp_vec = Vec::new();
    temp_vec.push(1);
    temp_vec.push(2);
    temp_vec.push(3);
    temp_vec
}

Мы определили макрос, который может принимать любое количество аргументов любого типа и может генерировать код для создания вектора, содержащего указанные элементы.

Чтобы узнать больше о том, как писать макросы, обратитесь к онлайн-документации или другим ресурсам, таким как “Маленькая книга макросов Rust”, начатая Дэниелом Кипом и продолженная Лукасом Виртом.

Процедурные макросы для генерации кода из атрибутов

Вторая форма макросов — это процедурный макрос, который действует больше как функция (и является типом процедуры). Процедурные макросы принимают некоторый код в качестве входных данных, работают с этим кодом и производят некоторый код в качестве выходных данных, а не сопоставляют шаблоны и заменяют код другим кодом, как это делают декларативные макросы. Три вида процедурных макросов — это пользовательский derive, атрибутоподобные и функциеподобные, и все они работают аналогичным образом.

При создании процедурных макросов определения должны находиться в их собственном крейте со специальным типом крейта. Это связано со сложными техническими причинами, которые мы надеемся устранить в будущем. В Листинге 20-36 мы показываем, как определить процедурный макрос, где some_attribute является заполнителем для использования конкретной разновидности макроса.

Filename: src/lib.rs
use proc_macro;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
Listing 20-36: Пример определения процедурного макроса

Функция, которая определяет процедурный макрос, принимает TokenStream в качестве входных данных и производит TokenStream в качестве выходных данных. Тип TokenStream определен крейтом proc_macro, который включен в Rust, и представляет последовательность токенов. Это ядро макроса: исходный код, с которым работает макрос, составляет входной TokenStream, а код, который производит макрос, является выходным TokenStream. Функция также имеет прикрепленный к ней атрибут, который указывает, какой вид процедурного макроса мы создаем. Мы можем иметь несколько видов процедурных макросов в одном крейте.

Давайте посмотрим на различные виды процедурных макросов. Мы начнем с пользовательского макроса derive, а затем объясним небольшие различия, которые делают другие формы отличными.

Как написать пользовательский макрос derive

Давайте создадим крейт с именем hello_macro, который определяет трейт с именем HelloMacro с одной связанной функцией с именем hello_macro. Вместо того, чтобы заставлять наших пользователей реализовывать трейт HelloMacro для каждого из их типов, мы предоставим процедурный макрос, чтобы пользователи могли аннотировать свой тип #[derive(HelloMacro)], чтобы получить реализацию по умолчанию функции hello_macro. Реализация по умолчанию напечатает Hello, Macro! My name is TypeName!, где TypeName — это имя типа, для которого был определен этот трейт. Другими словами, мы напишем крейт, который позволит другому программисту написать код, как в Листинге 20-37, используя наш крейт.

Filename: src/main.rs
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
    Pancakes::hello_macro();
}
Listing 20-37: Код, который сможет написать пользователь нашего крейта при использовании нашего процедурного макроса

Этот код напечатает Hello, Macro! My name is Pancakes!, когда мы закончим. Первый шаг — создать новый библиотечный крейт, вот так:

$ cargo new hello_macro --lib

Далее мы определим трейт HelloMacro и его связанную функцию:

Filename: src/lib.rs
pub trait HelloMacro {
    fn hello_macro();
}
Listing 20-38: Простой трейт, который мы будем использовать с макросом derive

У нас есть трейт и его функция. На данный момент пользователь нашего крейта мог бы реализовать трейт для достижения желаемой функциональности, как в Листинге 20-39.

Filename: src/main.rs
use hello_macro::HelloMacro;

struct Pancakes;

impl HelloMacro for Pancakes {
    fn hello_macro() {
        println!("Hello, Macro! My name is Pancakes!");
    }
}

fn main() {
    Pancakes::hello_macro();
}
Listing 20-39: Как это выглядело бы, если бы пользователи написали ручную реализацию трейта HelloMacro

Однако им пришлось бы писать блок реализации для каждого типа, который они хотели бы использовать с hello_macro; мы хотим избавить их от необходимости делать эту работу.

Кроме того, мы еще не можем предоставить функцию hello_macro с реализацией по умолчанию, которая напечатает имя типа, для которого реализован трейт: Rust не имеет возможностей рефлексии, поэтому он не может найти имя типа во время выполнения. Нам нужен макрос для генерации кода во время компиляции.

Следующий шаг — определить процедурный макрос. На момент написания этой статьи процедурные макросы должны находиться в их собственном крейте. В конечном итоге это ограничение может быть снято. Соглашение о структурировании крейтов и макро-крейтов следующее: для крейта с именем foo пользовательский процедурный макрос derive называется foo_derive. Давайте начнем новый крейт с именем hello_macro_derive внутри нашего проекта hello_macro:

$ cargo new hello_macro_derive --lib

Наши два крейта тесно связаны, поэтому мы создаем крейт процедурного макроса внутри каталога нашего крейта hello_macro. Если мы изменим определение трейта в hello_macro, нам придется изменить реализацию процедурного макроса в hello_macro_derive также. Два крейта нужно будет публиковать отдельно, и программисты, использующие эти крейты, должны будут добавить оба в качестве зависимостей и ввести их оба в область видимости. Вместо этого мы могли бы сделать так, чтобы крейт hello_macro использовал hello_macro_derive в качестве зависимости и реэкспортировал код процедурного макроса. Однако способ, которым мы структурировали проект, делает возможным для программистов использовать hello_macro, даже если они не хотят функциональность derive.

Нам нужно объявить крейт hello_macro_derive как крейт процедурного макроса. Нам также понадобится функциональность из крейтов syn и quote, как вы увидите через мгновение, поэтому нам нужно добавить их в качестве зависимостей. Добавьте следующее в файл Cargo.toml для hello_macro_derive:

Filename: hello_macro_derive/Cargo.toml
[lib]
proc-macro = true

[dependencies]
syn = "2.0"
quote = "1.0"

Чтобы начать определение процедурного макроса, поместите код из Листинга 20-40 в ваш файл src/lib.rs для крейта hello_macro_derive. Обратите внимание, что этот код не скомпилируется, пока мы не добавим определение для функции impl_hello_macro.

Filename: hello_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construct a representation of Rust code as a syntax tree
    // that we can manipulate.
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation.
    impl_hello_macro(&ast)
}
Listing 20-40: Код, который потребуется большинству крейтов процедурных макросов для обработки кода Rust

Обратите внимание, что мы разделили код на функцию hello_macro_derive, которая отвечает за разбор TokenStream, и функцию impl_hello_macro, которая отвечает за преобразование синтаксического дерева: это делает написание процедурного макроса более удобным. Код во внешней функции (hello_macro_derive в данном случае) будет одинаковым почти для каждого крейта процедурного макроса, который вы увидите или создадите. Код, который вы укажете в теле внутренней функции (impl_hello_macro в данном случае), будет отличаться в зависимости от цели вашего процедурного макроса.

Мы представили три новых крейта: proc_macro, syn и quote. Крейт proc_macro поставляется с Rust, поэтому нам не нужно было добавлять его в зависимости в Cargo.toml. Крейт proc_macro — это API компилятора, который позволяет нам читать и манипулировать кодом Rust из нашего кода.

Крейт syn разбирает код Rust из строки в структуру данных, с которой мы можем выполнять операции. Крейт quote превращает структуры данных syn обратно в код Rust. Эти крейты значительно упрощают разбор любого вида кода Rust, который мы могли бы захотеть обработать: написание полного парсера для кода Rust — непростая задача.

Функция hello_macro_derive будет вызвана, когда пользователь нашей библиотеки укажет #[derive(HelloMacro)] для типа. Это возможно, потому что мы аннотировали функцию hello_macro_derive здесь с помощью proc_macro_derive и указали имя HelloMacro, которое соответствует имени нашего трейта; это соглашение, которому следует большинство процедурных макросов.

Функция hello_macro_derive сначала преобразует input из TokenStream в структуру данных, которую мы затем можем интерпретировать и выполнять операции. Вот где вступает в игру syn. Функция parse в syn принимает TokenStream и возвращает структуру DeriveInput, представляющую разобранный код Rust. Листинг 20-41 показывает соответствующие части структуры DeriveInput, которую мы получаем при разборе строки struct Pancakes;.

DeriveInput {
    // --snip--

    ident: Ident {
        ident: "Pancakes",
        span: #0 bytes(95..103)
    },
    data: Struct(
        DataStruct {
            struct_token: Struct,
            fields: Unit,
            semi_token: Some(
                Semi
            )
        }
    )
}
Listing 20-41: Экземпляр DeriveInput, который мы получаем при разборе кода, имеющего атрибут макроса в Листинге 20-37

Поля этой структуры показывают, что разобранный нами код Rust — это единичная структура с ident (идентификатором, означающим имя) Pancakes. В этой структуре есть больше полей для описания всех видов кода Rust; проверьте документацию syn для DeriveInput для получения дополнительной информации.

Скоро мы определим функцию impl_hello_macro, которая является местом, где мы построим новый код Rust, который хотим включить. Но прежде чем мы это сделаем, обратите внимание, что вывод для нашего макроса derive также является TokenStream. Возвращенный TokenStream добавляется к коду, который пишут пользователи нашего крейта, поэтому когда они компилируют свой крейт, они получат дополнительную функциональность, которую мы предоставляем в измененном TokenStream.

Вы могли заметить, что мы вызываем unwrap, чтобы заставить функцию hello_macro_derive паниковать, если вызов функции syn::parse не удается здесь. Необходимо, чтобы наш процедурный макрос паниковал при ошибках, потому что функции proc_macro_derive должны возвращать TokenStream, а не Result, чтобы соответствовать API процедурного макроса. Мы упростили этот пример, используя unwrap; в производственном коде вы должны предоставить более конкретные сообщения об ошибках о том, что пошло не так, используя panic! или expect.

Теперь, когда у нас есть код для преобразования аннотированного кода Rust из TokenStream в экземпляр DeriveInput, давайте сгенерируем код, который реализует трейт HelloMacro для аннотированного типа, как показано в Листинге 20-42.

Filename: hello_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construct a representation of Rust code as a syntax tree
    // that we can manipulate
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation
    impl_hello_macro(&ast)
}

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;
    let generated = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!("Hello, Macro! My name is {}!", stringify!(#name));
            }
        }
    };
    generated.into()
}
Listing 20-42: Реализация трейта HelloMacro с использованием разобранного кода Rust

Мы получаем экземпляр структуры Ident, содержащий имя (идентификатор) аннотированного типа, используя ast.ident. Структура в Листинге 20-33 показывает, что когда мы запускаем функцию impl_hello_macro для кода в Листинге 20-31, ident, который мы получим, будет иметь поле ident со значением "Pancakes". Таким образом, переменная name в Листинге 20-34 будет содержать экземпляр структуры Ident, который при печати будет строкой "Pancakes", именем структуры в Листинге 20-37.

Макрос quote! позволяет нам определить код Rust, который мы хотим вернуть. Компилятор ожидает чего-то отличного от прямого результата выполнения макроса quote!, поэтому нам нужно преобразовать его в TokenStream. Мы делаем это, вызывая метод into, который потребляет это промежуточное представление и возвращает значение требуемого типа TokenStream.

Макрос quote! также предоставляет очень крутую механику шаблонов: мы можем ввести #name, и quote! заменит его значением в переменной name. Вы даже можете сделать некоторое повторение, подобное тому, как работают обычные макросы. Ознакомьтесь с документацией крейта quote для подробного введения.

Мы хотим, чтобы наш процедурный макрос сгенерировал реализацию нашего трейта HelloMacro для типа, который аннотировал пользователь, который мы можем получить, используя #name. Реализация трейта имеет одну функцию hello_macro, тело которой содержит функциональность, которую мы хотим предоставить: печать Hello, Macro! My name is, а затем имени аннотированного типа.

Макрос stringify!, используемый здесь, встроен в Rust. Он принимает выражение Rust, такое как 1 + 2, и во время компиляции превращает выражение в строковый литерал, такой как "1 + 2". Это отличается от format! или println!, макросов, которые вычисляют выражение, а затем превращают результат в String. Существует возможность того, что входные данные #name могут быть выражением для буквальной печати, поэтому мы используем stringify!. Использование stringify! также экономит выделение памяти, преобразуя #name в строковый литерал во время компиляции.

На этом этапе cargo build должна успешно завершиться как в hello_macro, так и в hello_macro_derive. Давайте подключим эти крейты к коду в Листинге 20-31, чтобы увидеть процедурный макрос в действии! Создайте новый бинарный проект в вашем каталоге projects, используя cargo new pancakes. Нам нужно добавить hello_macro и hello_macro_derive в качестве зависимостей в Cargo.toml крейта pancakes. Если вы публикуете свои версии hello_macro и hello_macro_derive на crates.io, они будут обычными зависимостями; если нет, вы можете указать их как зависимости path следующим образом:

hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }

Поместите код из Листинга 20-37 в src/main.rs и запустите cargo run: он должен напечатать Hello, Macro! My name is Pancakes! Реализация трейта HelloMacro из процедурного макроса была включена без того, чтобы крейт pancakes нуждался в ее реализации; #[derive(HelloMacro)] добавил реализацию трейта.

Далее давайте рассмотрим, как другие виды процедурных макросов отличаются от пользовательских макросов derive.

Атрибутоподобные макросы

Атрибутоподобные макросы похожи на пользовательские макросы derive, но вместо генерации кода для атрибута derive они позволяют вам создавать новые атрибуты. Они также более гибкие: derive работает только для структур и перечислений; атрибуты могут применяться и к другим элементам, таким как функции. Вот пример использования атрибутоподобного макроса. Скажем, у вас есть атрибут с именем route, который аннотирует функции при использовании фреймворка веб-приложений:

#[route(GET, "/")]
fn index() {

Этот атрибут #[route] будет определен фреймворком как процедурный макрос. Сигнатура функции определения макроса будет выглядеть так:

#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {

Здесь у нас есть два параметра типа TokenStream. Первый — для содержимого атрибута: части GET, "/". Второй — это тело элемента, к которому прикреплен атрибут: в данном случае fn index() {} и остальная часть тела функции.

Кроме этого, атрибутоподобные макросы работают так же, как пользовательские макросы derive: вы создаете крейт с типом крейта proc-macro и реализуете функцию, которая генерирует код, который вы хотите!

Функциеподобные макросы

Функциеподобные макросы определяют макросы, которые выглядят как вызовы функций. Подобно макросам macro_rules!, они более гибкие, чем функции; например, они могут принимать неизвестное количество аргументов. Однако макросы macro_rules! могут быть определены только с использованием синтаксиса, похожего на match, который мы обсуждали в разделе “Декларативные макросы с macro_rules! для общего метапрограммирования” ранее. Функциеподобные макросы принимают параметр TokenStream, и их определение манипулирует этим TokenStream, используя код Rust, как и два других типа процедурных макросов. Примером функциеподобного макроса является макрос sql!, который может быть вызван следующим образом:

let sql = sql!(SELECT * FROM posts WHERE id=1);

Этот макрос разберет SQL-оператор внутри него и проверит, что он синтаксически корректен, что является гораздо более сложной обработкой, чем может сделать макрос macro_rules!. Макрос sql! будет определен следующим образом:

#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {

Это определение похоже на сигнатуру пользовательского макроса derive: мы получаем токены, которые находятся внутри круглых скобок, и возвращаем код, который хотели сгенерировать.

Резюме

Фух! Теперь у вас есть некоторые возможности Rust в вашем наборе инструментов, которые вы, вероятно, не будете использовать часто, но вы будете знать, что они доступны в очень конкретных обстоятельствах. Мы представили несколько сложных тем, чтобы, когда вы столкнетесь с ними в предложениях сообщений об ошибках или в коде других людей, вы смогли распознать эти концепции и синтаксис. Используйте эту главу в качестве справочника, чтобы направить вас к решениям.

Далее мы применим все, что мы обсуждали на протяжении всей книги, на практике и сделаем еще один проект!

Финальный проект: создание многопоточного веб-сервера

Это было долгое путешествие, но мы дошли до конца книги. В этой главе мы вместе создадим ещё один проект, чтобы продемонстрировать некоторые концепции, рассмотренные в последних главах, а также повторим материал более ранних уроков.

В качестве финального проекта мы сделаем веб-сервер, который выводит «hello» и выглядит, как показано на Рисунке 21-1 в веб-браузере.

hello from rust

Рисунок 21-1: Наш финальный совместный проект

Вот наш план по созданию веб-сервера:

  1. Узнать немного о TCP и HTTP.
  2. Слушать TCP-подключения на сокете.
  3. Разбирать небольшое количество HTTP-запросов.
  4. Создавать корректный HTTP-ответ.
  5. Увеличить пропускную способность сервера с помощью пула потоков.

Прежде чем начать, стоит упомянуть два момента. Во-первых, метод, который мы будем использовать, не будет лучшим способом создания веб-сервера на Rust. Члены сообщества опубликовали множество готовых к использованию крейтов на crates.io, которые предоставляют более полные реализации веб-серверов и пулов потоков, чем мы создадим. Однако наша цель в этой главе — помочь вам научиться, а не выбрать лёгкий путь. Поскольку Rust — это язык системного программирования, мы можем выбирать желаемый уровень абстракции и опускаться до более низкого уровня, чем это возможно или практично в других языках.

Во-вторых, мы не будем здесь использовать async и await. Создание пула потоков — это уже достаточно сложная задача, не говоря уже о построении асинхронного рантайма! Однако мы отметим, как async и await могут применяться к некоторым из тех же проблем, которые мы увидим в этой главе. В конечном счёте, как мы отмечали в Главе 17, многие асинхронные рантаймы используют пулы потоков для управления своей работой.

Поэтому мы напишем базовый HTTP-сервер и пул потоков вручную, чтобы вы могли понять общие идеи и техники, стоящие за крейтами, которые вы, возможно, будете использовать в будущем.

Создание однопоточного веб-сервера

Мы начнём с создания работающего однопоточного веб-сервера. Прежде чем начать, давайте кратко рассмотрим протоколы, задействованные в построении веб-серверов. Детали этих протоколов выходят за рамки этой книги, но краткий обзор даст вам необходимую информацию.

Два основных протокола, используемых в веб-серверах, — это Hypertext Transfer Protocol (HTTP) и Transmission Control Protocol (TCP). Оба протокола являются протоколами запрос-ответ, что означает: клиент инициирует запросы, а сервер слушает запросы и предоставляет ответ клиенту. Содержание этих запросов и ответов определяется протоколами.

TCP — это протокол более низкого уровня, который описывает детали передачи информации между серверами, но не определяет, что это за информация. HTTP строится поверх TCP, определяя содержимое запросов и ответов. Технически возможно использовать HTTP с другими протоколами, но в подавляющем большинстве случаев HTTP отправляет свои данные через TCP. Мы будем работать с “сырыми” байтами TCP- и HTTP-запросов и ответов.

Прослушивание TCP-соединения

Нашему веб-серверу нужно прослушивать TCP-соединение, поэтому это первая часть, над которой мы поработаем. Стандартная библиотека предлагает модуль std::net, который позволяет это сделать. Давайте создадим новый проект обычным способом:

$ cargo new hello
     Created binary (application) `hello` project
$ cd hello

Теперь введите код из Листинга 21-1 в файл src/main.rs, чтобы начать. Этот код будет прослушивать локальный адрес 127.0.0.1:7878 на предмет входящих TCP-потоков. Когда придет входящий поток, он выведет Connection established!.

Filename: src/main.rs
use std::net::TcpListener;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        println!("Connection established!");
    }
}
Listing 21-1: Прослушивание входящих потоков и вывод сообщения при получении потока

Используя TcpListener, мы можем прослушивать TCP-соединения по адресу 127.0.0.1:7878. В адресе часть перед двоеточием — это IP-адрес, представляющий ваш компьютер (он одинаков на всех компьютерах и не относится конкретно к компьютеру автора), а 7878 — это порт. Мы выбрали этот порт по двум причинам: обычно HTTP не принимается на этом порту, поэтому наш сервер вряд ли конфликтовать с любым другим веб-сервером, который может работать на вашей машине, и 7878 — это слово “rust”, набранное на телефоне.

Функция bind в этом сценарии работает подобно функции new: она возвращает новый экземпляр TcpListener. Функция называется bind, потому что в сетевых технологиях подключение к порту для прослушивания известно как “привязка к порту”.

Функция bind возвращает Result<T, E>, что указывает на возможность сбоя при привязке. Например, подключение к порту 80 требует прав администратора (неадминистраторы могут прослушивать только порты выше 1023), поэтому если мы попытаемся подключиться к порту 80, не будучи администратором, привязка не сработает. Привязка также не сработает, например, если мы запустим два экземпляра нашей программы и, следовательно, будем иметь две программы, прослушивающие один и тот же порт. Поскольку мы пишем базовый сервер только для учебных целей, мы не будем беспокоиться об обработке таких ошибок; вместо этого мы используем unwrap, чтобы остановить программу при возникновении ошибок.

Метод incoming у TcpListener возвращает итератор, который дает нам последовательность потоков (более конкретно, потоков типа TcpStream). Один поток представляет собой открытое соединение между клиентом и сервером. Соединение — это название для полного процесса запроса и ответа, в котором клиент подключается к серверу, сервер генерирует ответ и сервер закрывает соединение. Таким образом, мы будем читать из TcpStream, чтобы увидеть, что отправил клиент, а затем запишем наш ответ в поток, чтобы отправить данные обратно клиенту. В целом, этот цикл for будет обрабатывать каждое соединение по очереди и предоставлять нам серию потоков для обработки.

На данный момент наша обработка потока заключается в вызове unwrap для прекращения программы, если в потоке есть ошибки; если ошибок нет, программа выводит сообщение. Мы добавим больше функциональности для случая успеха в следующем листинге. Причина, по которой мы можем получать ошибки от метода incoming при подключении клиента к серверу, в том, что мы на самом деле не итерируем по соединениям. Вместо этого мы итерируем по попыткам соединения. Соединение может быть неуспешным по ряду причин, многие из них специфичны для операционной системы. Например, во многих операционных системах есть ограничение на количество одновременных открытых соединений, которые они могут поддерживать; новые попытки соединения, превышающие это число, будут вызывать ошибку, пока некоторые из открытых соединений не будут закрыты.

Давайте попробуем запустить этот код! Выполните cargo run в терминале, а затем загрузите 127.0.0.1:7878 в веб-браузере. Браузер должен показать сообщение об ошибке, например “Connection reset”, потому что сервер в данный момент не отправляет обратно никаких данных. Но когда вы посмотрите в ваш терминал, вы должны увидеть несколько сообщений, которые были выведены, когда браузер подключился к серверу!

     Running `target/debug/hello`
Connection established!
Connection established!
Connection established!

Иногда вы увидите несколько сообщений, выведенных для одного запроса браузера; причина может быть в том, что браузер делает запрос на страницу, а также запрос других ресурсов, например, иконки favicon.ico, которая отображается на вкладке браузера.

Это также может быть связано с тем, что браузер пытается подключиться к серверу несколько раз, потому что сервер не отвечает никакими данными. Когда stream выходит из области видимости и удаляется в конце цикла, соединение закрывается как часть реализации drop. Браузеры иногда справляются с закрытыми соединениями повторными попытками, потому что проблема может быть временной.

Браузеры также иногда открывают несколько соединений с сервером, не отправляя никаких запросов, чтобы, если они позже отправят запросы, они могли произойти быстрее. Когда это происходит, наш сервер увидит каждое соединение, независимо от того, есть ли какие-либо запросы по этому соединению. Многие версии браузеров на базе Chrome делают это, например; вы можете отключить эту оптимизацию, используя режим приватного просмотра или используя другой браузер.

Важный фактор в том, что мы успешно получили дескриптор TCP-соединения!

Не забудьте остановить программу, нажав ctrl-c, когда вы закончите работать с определенной версией кода. Затем перезапустите программу, выполнив команду cargo run после того, как вы внесли каждый набор изменений в код, чтобы убедиться, что запускаете самый новый код.

Чтение запроса

Давайте реализуем функциональность для чтения запроса из браузера! Чтобы разделить заботы о сначала получении соединения, а затем выполнении некоторых действий с соединением, мы начнем новую функцию для обработки соединений. В этой новой функции handle_connection мы будем читать данные из TCP-потока и выводить их, чтобы увидеть данные, отправляемые из браузера. Измените код, чтобы он выглядел как в Листинге 21-2.

Filename: src/main.rs
use std::{
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    println!("Request: {http_request:#?}");
}
Listing 21-2: Чтение из TcpStream и вывод данных

Мы подключаем std::io::prelude и std::io::BufReader в область видимости, чтобы получить доступ к типаж и типам, которые позволяют нам читать из потока и писать в него. В цикле for в функции main, вместо вывода сообщения о том, что мы установили соединение, мы теперь вызываем новую функцию handle_connection и передаем в нее stream.

В функции handle_connection мы создаем новый экземпляр BufReader, который оборачивает ссылку на stream. BufReader добавляет буферизацию, управляя вызовами методов типажа std::io::Read за нас.

Мы создаем переменную с именем http_request для сбора строк запроса, который браузер отправляет нашему серверу. Мы указываем, что хотим собрать эти строки в вектор, добавив аннотацию типа Vec<_>.

BufReader реализует типаж std::io::BufRead, который предоставляет метод lines. Метод lines возвращает итератор Result<String, std::io::Error>, разделяя поток данных каждый раз, когда видит байт новой строки. Чтобы получить каждую String, мы отображаем (map) и раскрываем (unwrap) каждый Result. Result может быть ошибкой, если данные не являются допустимым UTF-8 или если возникла проблема при чтении из потока. Опять же, рабочая программа должна обрабатывать эти ошибки более изящно, но мы выбираем остановку программы в случае ошибки для простоты.

Браузер сигнализирует о конце HTTP-запроса, отправляя два символа новой строки подряд, поэтому чтобы получить один запрос из потока, мы берем строки до тех пор, пока не получим строку, которая является пустой строкой. После того как мы собрали строки в вектор, мы выводим их, используя форматирование для отладки, чтобы мы могли посмотреть инструкции, которые веб-браузер отправляет нашему серверу.

Давайте попробуем этот код! Запустите программу и сделайте запрос в веб-браузере снова. Обратите внимание, что мы все еще получим страницу ошибки в браузере, но вывод нашей программы в терминале теперь будет выглядеть примерно так:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished dev [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/hello`
Request: [
    "GET / HTTP/1.1",
    "Host: 127.0.0.1:7878",
    "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0",
    "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
    "Accept-Language: en-US,en;q=0.5",
    "Accept-Encoding: gzip, deflate, br",
    "DNT: 1",
    "Connection: keep-alive",
    "Upgrade-Insecure-Requests: 1",
    "Sec-Fetch-Dest: document",
    "Sec-Fetch-Mode: navigate",
    "Sec-Fetch-Site: none",
    "Sec-Fetch-User: ?1",
    "Cache-Control: max-age=0",
]

В зависимости от вашего браузера вывод может немного отличаться. Теперь, когда мы выводим данные запроса, мы можем понять, почему получаем несколько соединений от одного запроса браузера, посмотрев на путь после GET в первой строке запроса. Если повторяющиеся соединения все запрашивают /, мы знаем, что браузер пытается загружать / повторно, потому что не получает ответ от нашей программы.

Давайте разберем эти данные запроса, чтобы понять, что браузер просит нашу программу сделать.

Более пристальный взгляд на HTTP-запрос

HTTP — это текстовый протокол, и запрос имеет следующий формат:

Method Request-URI HTTP-Version CRLF
headers CRLF
message-body

Первая строка — это строка запроса, которая содержит информацию о том, что запрашивает клиент. Первая часть строки запроса указывает метод, используемый, например GET или POST, который описывает, как клиент делает этот запрос. Наш клиент использовал запрос GET, что означает, что он запрашивает информацию.

Следующая часть строки запроса — это /, которая указывает унифицированный идентификатор ресурса (URI), который запрашивает клиент: URI почти, но не совсем, то же самое, что унифицированный локатор ресурса (URL). Разница между URI и URL не важна для наших целей в этой главе, но спецификация HTTP использует термин URI, поэтому мы можем мысленно заменить URL на URI здесь.

Последняя часть — это версия HTTP, которую использует клиент, а затем строка запроса заканчивается последовательностью CRLF. (CRLF означает возврат каретки и перевод строки, термины из эпохи печатных машинок!) Последовательность CRLF также может быть записана как \r\n, где \r — это возврат каретки, а \n — перевод строки. Последовательность CRLF отделяет строку запроса от остальных данных запроса. Обратите внимание, что когда CRLF выводится, мы видим начало новой строки, а не \r\n.

Глядя на данные строки запроса, которые мы получили от запуска нашей программы до сих пор, мы видим, что GET — это метод, / — это запрашиваемый URI, а HTTP/1.1 — это версия.

После строки запроса оставшиеся строки, начиная с Host: и далее, — это заголовки. Запросы GET не имеют тела.

Попробуйте сделать запрос из другого браузера или запросите другой адрес, например 127.0.0.1:7878/test, чтобы увидеть, как изменяются данные запроса.

Теперь, когда мы знаем, что просит браузер, давайте отправим обратно какие-нибудь данные!

Написание ответа

Мы собираемся реализовать отправку данных в ответ на запрос клиента. Ответы имеют следующий формат:

HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body

Первая строка — это строка состояния, которая содержит версию HTTP, используемую в ответе, числовой код состояния, обобщающий результат запроса, и фразу причины, которая предоставляет текстовое описание кода состояния. После последовательности CRLF идут любые заголовки, еще одна последовательность CRLF и тело ответа.

Вот пример ответа, который использует HTTP версии 1.1 и имеет код состояния 200, фразу причины OK, без заголовков и без тела:

HTTP/1.1 200 OK\r\n\r\n

Код состояния 200 — это стандартный ответ об успехе. Текст — это крошечный успешный HTTP-ответ. Давайте запишем это в поток как наш ответ на успешный запрос! Из функции handle_connection удалите println!, который выводил данные запроса, и замените его кодом из Листинга 21-3.

Filename: src/main.rs
use std::{
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let response = "HTTP/1.1 200 OK\r\n\r\n";

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-3: Запись крошечного успешного HTTP-ответа в поток

Первая новая строка определяет переменную response, которая содержит данные сообщения об успехе. Затем мы вызываем as_bytes для нашей response, чтобы преобразовать строковые данные в байты. Метод write_all на stream принимает &[u8] и отправляет эти байты напрямую по соединению. Поскольку операция write_all может завершиться неудачей, мы используем unwrap на любом результате ошибки, как и раньше. Опять же, в реальном приложении вы бы добавили обработку ошибок здесь.

С этими изменениями давайте запустим наш код и сделаем запрос. Мы больше не выводим никакие данные в терминал, поэтому мы не увидим никакого вывода, кроме вывода от Cargo. Когда вы загрузите 127.0.0.1:7878 в веб-браузере, вы должны получить пустую страницу вместо ошибки. Вы только что вручную закодировали получение HTTP-запроса и отправку ответа!

Возврат реального HTML

Давайте реализуем функциональность для возврата большего, чем пустая страница. Создайте новый файл hello.html в корневой директории вашего проекта, не в директории src. Вы можете ввести любой HTML, который хотите; Листинг 21-4 показывает один из возможных вариантов.

Filename: hello.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Hello!</h1>
    <p>Hi from Rust</p>
  </body>
</html>
Listing 21-4: Пример HTML-файла для возврата в ответе

Это минимальный документ HTML5 с заголовком и некоторым текстом. Чтобы вернуть его с сервера при получении запроса, мы изменим handle_connection, как показано в Листинге 21-5, чтобы прочитать HTML-файл, добавить его в ответ в качестве тела и отправить.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};
// --snip--

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let status_line = "HTTP/1.1 200 OK";
    let contents = fs::read_to_string("hello.html").unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-5: Отправка содержимого hello.html в качестве тела ответа

Мы добавили fs в инструкцию use, чтобы подключить модуль файловой системы стандартной библиотеки в область видимости. Код для чтения содержимого файла в строку должен выглядеть знакомо; мы использовали его, когда читали содержимое файла для нашего проекта ввода-вывода в Листинге 12-4.

Затем мы используем format!, чтобы добавить содержимое файла в качестве тела успешного ответа. Чтобы обеспечить допустимый HTTP-ответ, мы добавляем заголовок Content-Length, который установлен в размер нашего тела ответа, в данном случае размер hello.html.

Запустите этот код с помощью cargo run и загрузите 127.0.0.1:7878 в вашем браузере; вы должны увидеть ваш HTML, отрендеренный!

В настоящее время мы игнорируем данные запроса в http_request и просто отправляем обратно содержимое HTML-файла безоговорочно. Это означает, что если вы попробуете запросить 127.0.0.1:7878/something-else в браузере, вы все равно получите обратно этот же HTML-ответ. В данный момент наш сервер очень ограничен и не делает то, что делают большинство веб-серверов. Мы хотим настраивать наши ответы в зависимости от запроса и отправлять HTML-файл только для корректно сформированного запроса к / .

Проверка запроса и выборочный ответ

Сейчас наш веб-сервер будет возвращать HTML из файла независимо от того, что запросил клиент. Давайте добавим функциональность для проверки того, что браузер запрашивает /, прежде чем возвращать HTML-файл, и возвращать ошибку, если браузер запрашивает что-либо еще. Для этого нам нужно изменить handle_connection, как показано в Листинге 21-6. Этот новый код проверяет содержимое полученного запроса против того, как выглядит запрос GET к пути /, и добавляет блоки if и else для разной обработки запросов.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}
// --snip--

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("hello.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    } else {
        // some other request
    }
}
Listing 21-6: Обработка запросов к / иначе, чем другие запросы

Мы будем смотреть только на первую строку HTTP-запроса, поэтому вместо чтения весь запрос в вектор мы вызываем next, чтобы получить первый элемент из итератора. Первый unwrap заботится об Option и останавливает программу, если итератор не имеет элементов. Второй unwrap обрабатывает Result и имеет тот же эффект, что и unwrap, который был добавлен в map в Листинге 21-2.

Затем мы проверяем request_line, чтобы увидеть, равна ли она строке запроса GET-запроса к пути /. Если это так, блок if возвращает содержимое нашего HTML-файла.

Если request_line не равна GET-запросу к пути /, это означает, что мы получили какой-то другой запрос. Мы добавим код в блок else в ближайшее время, чтобы отвечать на все остальные запросы.

Запустите этот код сейчас и запросите 127.0.0.1:7878; вы должны получить HTML из файла hello.html. Если вы сделаете любой другой запрос, например 127.0.0.1:7878/foo, вы получите ошибку соединения, такую как те, которые вы видели при запуске кода в Листинге 21-1 и Листинге 21-2.

Теперь давайте добавим код из Листинга 21-7 в блок else, чтобы вернуть ответ с кодом состояния 404, который сигнализирует, что содержимое для запроса не было найдено. Мы также вернем некоторый HTML для страницы, которая отобразится в браузере, указывая ответ конечному пользователю.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("hello.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    // --snip--
    } else {
        let status_line = "HTTP/1.1 404 NOT FOUND";
        let contents = fs::read_to_string("404.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    }
}
Listing 21-7: Ответ с кодом состояния 404 и страницей ошибки, если запрошено что-либо кроме /

Здесь наш ответ имеет строку состояния с кодом 404 и фразой причины NOT FOUND. Тело ответа будет HTML из файла 404.html. Вам нужно будет создать файл 404.html рядом с hello.html для страницы ошибки; снова чувствуйте себя свободно использовать любой HTML, который хотите, или использовать пример HTML в Листинге 21-8.

Filename: 404.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Oops!</h1>
    <p>Sorry, I don't know what you're asking for.</p>
  </body>
</html>
Listing 21-8: Пример содержимого для страницы, отправляемой с любым ответом 404

С этими изменениями запустите ваш сервер снова. Запрос 127.0.0.1:7878 должен возвращать содержимое hello.html, а любой другой запрос, например 127.0.0.1:7878/foo, должен возвращать HTML ошибки из 404.html.

Немного рефакторинга

В данный момент блоки if и else имеют много повторений: они оба читают файлы и записывают содержимое файлов в поток. Единственные различия — это строка состояния и имя файла. Давайте сделаем код более лаконичным, вынеся эти различия в отдельные строки if и else, которые назначат значения строки состояния и имени файла переменным; затем мы можем использовать эти переменные безоговорочно в коде для чтения файла и записи ответа. Листинг 21-9 показывает полученный код после замены больших блоков if и else.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}
// --snip--

fn handle_connection(mut stream: TcpStream) {
    // --snip--
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = if request_line == "GET / HTTP/1.1" {
        ("HTTP/1.1 200 OK", "hello.html")
    } else {
        ("HTTP/1.1 404 NOT FOUND", "404.html")
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-9: Рефакторинг блоков if и else для содержания только кода, который отличается между двумя случаями

Теперь блоки if и else возвращают только соответствующие значения для строки состояния и имени файла в кортеже; затем мы используем деструктуризацию, чтобы назначить эти два значения status_line и filename, используя шаблон в инструкции let, как обсуждалось в Главе 19.

Ранее дублировавшийся код теперь находится вне блоков if и else и использует переменные status_line и filename. Это облегчает визуализацию различий между двумя случаями, и это означает, что у нас есть только одно место для обновления кода, если мы хотим изменить то, как работают чтение файла и запись ответа. Поведение кода в Листинге 21-9 будет таким же, как в Листинге 21-7.

Отлично! У нас теперь простой веб-сервер примерно в 40 строках кода Rust, который отвечает на один запрос страницей с контентом и отвечает на все остальные запросы ответом 404.

В настоящее время наш сервер работает в одном потоке, что означает, что он может обслуживать только один запрос за раз. Давайте рассмотрим, как это может быть проблемой, смоделировав некоторые медленные запросы. Затем мы исправим это, чтобы наш сервер мог обрабатывать несколько запросов одновременно.

Превращение однопоточного сервера в многопоточный

В данный момент сервер обрабатывает каждый запрос последовательно, то есть не начнёт обрабатывать второе соединение, пока не завершит первое. Если сервер получает всё больше и больше запросов, такое последовательное выполнение становится всё менее оптимальным. Если сервер получает запрос, обработка которого занимает много времени, последующие запросы будут вынуждены ждать, пока долгий запрос не завершится, даже если новые запросы можно обработать быстро. Нам нужно это исправить, но сначала посмотрим на проблему в действии.

Имитация медленного запроса в текущей реализации сервера

Посмотрим, как медленная обработка запроса может повлиять на другие запросы к нашей текущей реализации сервера. Листинг 21-10 реализует обработку запроса к /sleep с имитацией медленного ответа, из-за которого сервер будет ждать пять секунд перед ответом.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};
// --snip--

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    // --snip--

    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    // --snip--

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-10: Имитация медленного запроса с помощью паузы на 5 секунд

Мы заменили if на match, так как теперь у нас три случая. Нам нужно явно сопоставлять со срезом request_line для сравнения со строковыми литералами; match не выполняет автоматическое обращение и разыменование, как это делает метод равенства.

Первая ветка такая же, как блок if из листинга 21-9. Вторая ветка соответствует запросу к /sleep. Когда такой запрос получен, сервер будет ждать пять секунд, прежде чем отобразить успешную HTML-страницу. Третья ветка такая же, как блок else из листинга 21-9.

Вы видите, насколько примитивен наш сервер: реальные библиотеки обрабатывают распознавание множества запросов гораздо менее многословно!

Запустите сервер, используя cargo run. Затем откройте два окна браузера: одно для http://127.0.0.1:7878/, а другое для http://127.0.0.1:7878/sleep. Если вы несколько раз откроете URI /, как и раньше, вы увидите, что он отвечает быстро. Но если вы откроете /sleep, а затем загрузите /, вы увидите, что / ждёт, пока sleep полностью отслушает свои пять секунд.

Существует несколько техник, которые мы могли бы использовать, чтобы избежать накопления запросов позади медленного, включая использование async, как мы делали в главе 17; та, которую мы реализуем, — это пул потоков.

Повышение пропускной способности с помощью пула потоков

Пул потоков — это группа порождённых потоков, которые ждут и готовы обработать задачу. Когда программа получает новую задачу, она назначает один из потоков в пуле для этой задачи, и этот поток обработает её. Остальные потоки в пуле доступны для обработки любых других задач, поступающих, пока первый поток занят. Когда первый поток завершает обработку своей задачи, он возвращается в пул простаивающих потоков, готовый обработать новую задачу. Пул потоков позволяет обрабатывать соединения параллельно, увеличивая пропускную способность сервера.

Мы ограничим количество потоков в пуле небольшим числом, чтобы защититься от DoS-атак; если бы наша программа создавала новый поток для каждого поступающего запроса, кто-то, делающий 10 миллионов запросов к нашему серверу, мог бы навредить, исчерпав все ресурсы сервера и остановив обработку запросов.

Вместо создания неограниченного количества потоков у нас будет фиксированное количество потоков, ожидающих в пуле. Поступающие запросы отправляются в пул для обработки. Пул будет поддерживать очередь входящих запросов. Каждый из потоков в пуле будет забирать запрос из этой очереди, обрабатывать его, а затем запрашивать у очереди следующий запрос. При таком дизайне мы можем обрабатывать до N запросов параллельно, где N — количество потоков. Если каждый поток отвечает на долгий запрос, последующие запросы всё ещё могут накапливаться в очереди, но мы увеличили количество долгих запросов, которые можем обработать, прежде чем достигнем этой точки.

Эта техника — лишь одна из многих способов повысить пропускную способность веб-сервера. Другие варианты, которые вы могли бы изучить, — это модель fork/join, модель однопоточного асинхронного ввода-вывода и модель многопоточного асинхронного ввода-вывода. Если вас интересует эта тема, вы можете подробнее узнать о других решениях и попробовать их реализовать; с низкоуровневым языком, таким как Rust, все эти варианты возможны.

Прежде чем мы начнём реализовывать пул потоков, давайте поговорим о том, как должно выглядеть его использование. Когда вы пытаетесь спроектировать код, написание клиентского интерфейса сначала может помочь направить ваш дизайн. Напишите API кода так, чтобы он был структурирован так, как вы хотите его вызывать; затем реализуйте функциональность внутри этой структуры, а не реализуйте функциональность, а потом проектируйте публичный API.

Подобно тому, как мы использовали разработку через тесты в проекте в главе 12, здесь мы будем использовать разработку, управляемую компилятором. Мы напишем код, который вызывает функции, которые мы хотим, а затем посмотрим на ошибки компилятора, чтобы определить, что нам нужно изменить дальше, чтобы заставить код работать. Однако перед этим мы изучим технику, которую мы не будем использовать в качестве отправной точки.

Создание потока для каждого запроса

Сначала давайте рассмотрим, как мог бы выглядеть наш код, если бы он действительно создавал новый поток для каждого соединения. Как упоминалось ранее, это не наш окончательный план из-за проблем с потенциальным созданием неограниченного количества потоков, но это отправная точка, чтобы сначала получить работающий многопоточный сервер. Затем мы добавим пул потоков как улучшение, и сравнение двух решений будет проще. Листинг 21-11 показывает изменения, которые нужно внести в main, чтобы создать новый поток для обработки каждого потока в цикле for.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        thread::spawn(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-11: Создание нового потока для каждого потока

Как вы узнали в главе 16, thread::spawn создаст новый поток, а затем выполнит код в замыкании в новом потоке. Если вы запустите этот код и загрузите /sleep в браузере, а затем / в двух других вкладках браузера, вы действительно увидите, что запросы к / не должны ждать завершения /sleep. Однако, как мы упоминали, это в конечном итоге перегрузит систему, потому что вы бы создавали новые потоки без какого-либо ограничения.

Вы также могли вспомнить из главы 17, что это именно та ситуация, где async и await действительно сияют! Держите это в уме, когда мы строим пул потоков и думаем, как всё выглядело бы по-другому или так же с async.

Создание конечного количества потоков

Мы хотим, чтобы наш пул потоков работал похожим, знакомым способом, чтобы переход от потоков к пулу потоков не требовал больших изменений в коде, который использует наш API. Листинг 21-12 показывает гипотетический интерфейс для структуры ThreadPool, который мы хотим использовать вместо thread::spawn.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-12: Наш идеальный интерфейс ThreadPool

Мы используем ThreadPool::new для создания нового пула потоков с настраиваемым количеством потоков, в данном случае четырьмя. Затем, в цикле for, pool.execute имеет интерфейс, аналогичный thread::spawn, в том смысле, что он принимает замыкание, которое пул должен выполнить для каждого потока. Нам нужно реализовать pool.execute так, чтобы он принимал замыкание и передавал его потоку в пуле для выполнения. Этот код ещё не скомпилируется, но мы попробуем, чтобы компилятор мог направлять нас, что нужно изменить дальше, чтобы заставить код работать.

Построение ThreadPool с использованием разработки, управляемой компилятором

Внесите изменения из листинга 21-12 в src/main.rs, а затем давайте используем ошибки компилятора от cargo check, чтобы управлять нашей разработкой. Вот первая ошибка, которую мы получаем:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0433]: failed to resolve: use of undeclared type `ThreadPool`
  --> src/main.rs:11:16
   |
11 |     let pool = ThreadPool::new(4);
   |                ^^^^^^^^^^ use of undeclared type `ThreadPool`

For more information about this error, try `rustc --explain E0433`.
error: could not compile `hello` (bin "hello") due to 1 previous error

Отлично! Эта ошибка говорит нам, что нам нужен тип или модуль ThreadPool, так что мы сейчас построим один. Наша реализация ThreadPool будет независима от вида работы, которую делает наш веб-сервер. Поэтому давайте переключим крейт hello из бинарного крейта в библиотечный крейт, чтобы хранить нашу реализацию ThreadPool. После того как мы перейдём на библиотечный крейт, мы также сможем использовать отдельную библиотеку пула потоков для любой работы, которую мы хотим выполнять с помощью пула потоков, а не только для обслуживания веб-запросов.

Создайте файл src/lib.rs, содержащий следующее, что является самым простым определением структуры ThreadPool, которое мы можем иметь на данный момент:

Filename: src/lib.rs
pub struct ThreadPool;

Затем отредактируйте файл main.rs, чтобыBring ThreadPool в область видимости из библиотечного крейта, добавив следующий код в начало src/main.rs:

Filename: src/main.rs
use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

Этот код всё ещё не будет работать, но давайте проверим его снова, чтобы получить следующую ошибку, которую нам нужно исправить:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0599]: no function or associated item named `new` found for struct `ThreadPool` in the current scope
  --> src/main.rs:12:28
   |
12 |     let pool = ThreadPool::new(4);
   |                            ^^^ function or associated item not found in `ThreadPool`

For more information about this error, try `rustc --explain E0599`.
error: could not compile `hello` (bin "hello") due to 1 previous error

Эта ошибка указывает, что следующее нам нужно создать связанную функцию с именем new для ThreadPool. Мы также знаем, что new должен иметь один параметр, который может принять 4 в качестве аргумента, и должен возвращать экземпляр ThreadPool. Давайте реализуем самую простую функцию new, которая будет иметь эти характеристики:

Filename: src/lib.rs
pub struct ThreadPool;

impl ThreadPool {
    pub fn new(size: usize) -> ThreadPool {
        ThreadPool
    }
}

Мы выбрали usize в качестве типа параметра size, потому что знаем, что отрицательное количество потоков не имеет смысла. Мы также знаем, что будем использовать это 4 как количество элементов в коллекции потоков, для чего и предназначен тип usize, как обсуждалось в “Integer Types” в главе 3.

Давайте проверим код снова:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0599]: no method named `execute` found for struct `ThreadPool` in the current scope
  --> src/main.rs:17:14
   |
17 |         pool.execute(|| {
   |         -----^^^^^^^ method not found in `ThreadPool`

For more information about this error, try `rustc --explain E0599`.
error: could not compile `hello` (bin "hello") due to 1 previous error

Теперь ошибка возникает потому, что у нас нет метода execute на ThreadPool. Вспомните из “Creating a Finite Number of Threads”, что мы решили, что наш пул потоков должен иметь интерфейс, аналогичный thread::spawn. Кроме того, мы реализуем функцию execute так, чтобы она принимала замыкание, которое ей дано, и передавала его простаивающему потоку в пуле для выполнения.

Мы определим метод execute на ThreadPool, чтобы он принимал замыкание в качестве параметра. Вспомните из “Moving Captured Values Out of the Closure and the Fn Traits” в главе 13, что мы можем принимать замыкания в качестве параметров с тремя разными типажами: Fn, FnMut и FnOnce. Нам нужно решить, какой вид замыкания использовать здесь. Мы знаем, что в конечном итоге сделаем что-то похожее на реализацию thread::spawn из стандартной библиотеки, так что мы можем посмотреть, какие границы имеет сигнатура thread::spawn для своего параметра. Документация показывает нам следующее:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    where
        F: FnOnce() -> T,
        F: Send + 'static,
        T: Send + 'static,

Параметр типа F — это тот, который нас интересует здесь; параметр типа T связан с возвращаемым значением, и нас он не интересует. Мы видим, что spawn использует FnOnce в качестве границы типажа на F. Это, вероятно, то, что мы хотим и здесь, потому что в конечном итоге мы передадим аргумент, который получаем в execute, в spawn. Мы можем быть ещё более уверены, что FnOnce — это типаж, который мы хотим использовать, потому что поток для выполнения запроса выполнит замыкание этого запроса только один раз, что соответствует Once в FnOnce.

Параметр типа F также имеет границу типажа Send и границу времени жизни 'static, которые полезны в нашей ситуации: нам нужно Send для передачи замыкания из одного потока в другой и 'static, потому что мы не знаем, сколько времени займёт выполнение потока. Давайте создадим метод execute на ThreadPool, который будет принимать обобщённый параметр типа F с этими границами:

Filename: src/lib.rs
pub struct ThreadPool;

impl ThreadPool {
    // --snip--
    pub fn new(size: usize) -> ThreadPool {
        ThreadPool
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

Мы всё ещё используем () после FnOnce, потому что этот FnOnce представляет замыкание, которое не принимает параметров и возвращает тип единицы (). Как и в определениях функций, возвращаемый тип может быть опущен из сигнатуры, но даже если у нас нет параметров, нам всё ещё нужны круглые скобки.

Опять же, это самая простая реализация метода execute: она ничего не делает, но мы только пытаемся заставить наш код скомпилироваться. Давайте проверим его снова:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.24s

Он компилируется! Но обратите внимание, что если вы попробуете cargo run и сделаете запрос в браузере, вы увидите ошибки в браузере, которые мы видели в начале главы. Наша библиотека на самом деле ещё не вызывает замыкание, переданное в execute!

Примечание: Высказывание, которое вы можете услышать о языках со строгими компиляторами, таких как Haskell и Rust, — «если код компилируется, он работает». Но это высказывание не универсально верно. Наш проект компилируется, но он абсолютно ничего не делает! Если бы мы строили реальный, завершённый проект, это было бы хорошее время, чтобы начать писать модульные тесты для проверки того, что код компилируется и имеет желаемое поведение.

Подумайте: что было бы здесь другим, если бы мы собирались выполнять future вместо замыкания?

Проверка количества потоков в new

Мы ничего не делаем с параметрами new и execute. Давайте реализуем тела этих функций с желаемым поведением. Для начала давайте подумаем о new. Ранее мы выбрали беззнаковый тип для параметра size, потому что пул с отрицательным количеством потоков не имеет смысла. Однако пул с нулевым количеством потоков также не имеет смысла, хотя ноль — это совершенно действительный usize. Мы добавим код для проверки, что size больше нуля, прежде чем возвращать экземпляр ThreadPool, и заставим программу завершиться с паникой, если она получит ноль, используя макрос assert!, как показано в листинге 21-13.

Filename: src/lib.rs
pub struct ThreadPool;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        ThreadPool
    }

    // --snip--
    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}
Listing 21-13: Реализация ThreadPool::new для паники, если size равен нулю

Мы также добавили некоторую документацию для нашего ThreadPool с помощью комментариев документации. Обратите внимание, что мы последовали хорошей практике документации, добавив раздел, который указывает на ситуации, в которых наша функция может вызвать панику, как обсуждалось в главе 14. Попробуйте запустить cargo doc --open и нажать на структуру ThreadPool, чтобы увидеть, как выглядят сгенерированные документы для new!

Вместо добавления макроса assert!, как мы сделали здесь, мы могли бы изменить new на build и вернуть Result, как мы делали с Config::build в проекте ввода-вывода в листинге 12-9. Но мы решили в этом случае, что попытка создать пул потоков без каких-либо потоков должна быть невосстанавливаемой ошибкой. Если вы чувствуете амбиции, попробуйте написать функцию с именем build со следующей сигнатурой, чтобы сравнить с функцией new:

pub fn build(size: usize) -> Result<ThreadPool, PoolCreationError> {

Создание пространства для хранения потоков

Теперь, когда у нас есть способ знать, что у нас есть допустимое количество потоков для хранения в пуле, мы можем создать эти потоки и сохранить их в структуре ThreadPool перед возвратом структуры. Но как нам «хранить» поток? Давайте ещё раз посмотрим на сигнатуру thread::spawn:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    where
        F: FnOnce() -> T,
        F: Send + 'static,
        T: Send + 'static,

Функция spawn возвращает JoinHandle<T>, где T — это тип, который возвращает замыкание. Давайте попробуем использовать JoinHandle тоже и посмотрим, что произойдёт. В нашем случае замыкания, которые мы передаём в пул потоков, будут обрабатывать соединение и ничего не возвращать, так что T будет типом единицы ().

Код в листинге 21-14 скомпилируется, но пока не создаёт никаких потоков. Мы изменили определение ThreadPool на хранение вектора экземпляров thread::JoinHandle<()>, инициализировали вектор ёмкостью size, настроили цикл for, который будет выполнять код для создания потоков, и вернули экземпляр ThreadPool, содержащий их.

Filename: src/lib.rs
use std::thread;

pub struct ThreadPool {
    threads: Vec<thread::JoinHandle<()>>,
}

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let mut threads = Vec::with_capacity(size);

        for _ in 0..size {
            // create some threads and store them in the vector
        }

        ThreadPool { threads }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}
Listing 21-14: Создание вектора для ThreadPool для хранения потоков

Мы привели std::thread в область видимости в библиотечном крейте, потому что используем thread::JoinHandle в качестве типа элементов в векторе в ThreadPool.

Как только действительный размер получен, наш ThreadPool создаёт новый вектор, который может содержать size элементов. Функция with_capacity выполняет ту же задачу, что и Vec::new, но с важным отличием: она предварительно выделяет пространство в векторе. Поскольку мы знаем, что нам нужно хранить size элементов в векторе, это выделение заранее немного более эффективно, чем использование Vec::new, который изменяет размер по мере вставки элементов.

Когда вы снова запустите cargo check, он должен завершиться успешно.

Структура Worker, ответственная за отправку кода из ThreadPool в поток

Мы оставили комментарий в цикле for в листинге 21-14 относительно создания потоков. Здесь мы посмотрим, как мы на самом деле создаём потоки. Стандартная библиотека предоставляет thread::spawn как способ создания потоков, и thread::spawn ожидает получить некоторый код, который поток должен выполнить сразу после создания. Однако в нашем случае мы хотим создать потоки и заставить их ждать код, который мы отправим позже. Реализация потоков в стандартной библиотеке не включает способа сделать это; мы должны реализовать это вручную.

Мы реализуем это поведение, введя новую структуру данных между ThreadPool и потоками, которая будет управлять этим новым поведением. Мы назовём эту структуру данных Worker, что является общим термином в реализациях пулов. Worker забирает код, который нужно запустить, и запускает код в потоке Worker.

Подумайте о людях, работающих на кухне в ресторане: работники ждут, пока поступят заказы от клиентов, а затем они ответственны за взятие этих заказов и их выполнение.

Вместо хранения вектора экземпляров JoinHandle<()> в пуле потоков, мы будем хранить экземпляры структуры Worker. Каждый Worker будет хранить один экземпляр JoinHandle<()>. Затем мы реализуем метод на Worker, который будет принимать замыкание кода для выполнения и отправлять его уже запущенному потоку для выполнения. Мы также дадим каждому Worker id, чтобы мы могли различать разные экземпляры Worker в пуле при ведении журнала или отладке.

Вот новый процесс, который будет происходить, когда мы создаём ThreadPool. Мы реализуем код, который отправляет замыкание в поток после того, как Worker настроен таким образом:

  1. Определите структуру Worker, которая хранит id и JoinHandle<()>.
  2. Измените ThreadPool на хранение вектора экземпляров Worker.
  3. Определите функцию Worker::new, которая принимает номер id и возвращает экземпляр Worker, который хранит id и поток, порождённый с пустым замыканием.
  4. В ThreadPool::new используйте счётчик цикла for для генерации id, создайте новый Worker с этим id и сохраните работника в векторе.

Если вы готовы к вызову, попробуйте реализовать эти изменения самостоятельно, прежде чем посмотреть код в листинге 21-15.

Готовы? Вот листинг 21-15 с одним из способов сделать предыдущие модификации.

Filename: src/lib.rs
use std::thread;

pub struct ThreadPool {
    workers: Vec<Worker>,
}

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id));
        }

        ThreadPool { workers }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize) -> Worker {
        let thread = thread::spawn(|| {});

        Worker { id, thread }
    }
}
Listing 21-15: Изменение ThreadPool на хранение экземпляров Worker вместо прямого хранения потоков

Мы изменили имя поля на ThreadPool с threads на workers, потому что теперь он хранит экземпляры Worker вместо экземпляров JoinHandle<()>. Мы используем счётчик в цикле for в качестве аргумента для Worker::new и сохраняем каждого нового Worker в векторе с именем workers.

Внешний код (как наш сервер в src/main.rs) не должен знать детали реализации относительно использования структуры Worker внутри ThreadPool, поэтому мы делаем структуру Worker и её функцию new приватными. Функция Worker::new использует id, который мы ей даём, и хранит экземпляр JoinHandle<()>, который создаётся путём порождения нового потока с использованием пустого замыкания.

Примечание: Если операционная система не может создать поток из-за нехватки системных ресурсов, thread::spawn вызовет панику. Это приведёт к панике всего нашего сервера, даже если создание некоторых потоков может завершиться успехом. Для простоты это поведение приемлемо, но в реальной реализации пула потоков вы, вероятно, захотели бы использовать std::thread::Builder и его метод spawn, который возвращает Result вместо этого.

Этот код скомпилируется и будет хранить количество экземпляров Worker, которое мы указали в качестве аргумента для ThreadPool::new. Но мы всё ещё не обрабатываем замыкание, которое получаем в execute. Давайте посмотрим, как это сделать дальше.

Отправка запросов потокам через каналы

Следующая проблема, которую мы решим, заключается в том, что замыкания, переданные thread::spawn, абсолютно ничего не делают. В данный момент мы получаем замыкание, которое хотим выполнить, в методе execute. Но нам нужно дать thread::spawn замыкание для выполнения, когда мы создаём каждого Worker во время создания ThreadPool.

Мы хотим, чтобы экземпляры Worker, которые мы только что создали, забирали код для выполнения из очереди, хранящейся в ThreadPool, и отправляли этот код в свой поток для выполнения.

Каналы, о которых мы узнали в главе 16 — простой способ общения между двумя потоками — идеально подойдут для этого случая. Мы будем использовать канал в качестве очереди задач, и execute отправит задачу из ThreadPool экземплярам Worker, которые отправят задачу в свой поток. Вот план:

  1. ThreadPool создаст канал и будет хранить отправителя.
  2. Каждый Worker будет хранить получателя.
  3. Мы создадим новую структуру Job, которая будет хранить замыкания, которые мы хотим отправить по каналу.
  4. Метод execute отправит задачу, которую он хочет выполнить, через отправителя.
  5. В своём потоке Worker будет бесконечно циклировать по своему получателю, запрашивая задачу и выполняя её, когда получит.

Давайте начнём с создания канала в ThreadPool::new и хранения отправителя в экземпляре ThreadPool, как показано в листинге 21-16. Структура Job пока ничего не хранит, но будет типом элемента, который мы отправляем по каналу.

Filename: src/lib.rs
use std::{sync::mpsc, thread};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id));
        }

        ThreadPool { workers, sender }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize) -> Worker {
        let thread = thread::spawn(|| {});

        Worker { id, thread }
    }
}
Listing 21-16: Изменение ThreadPool на хранение отправителя канала, передающего экземпляры Job

В ThreadPool::new мы создаём наш новый канал и заставляем пул хранить отправителя. Это успешно скомпилируется.

Давайте попробуем передать получатель канала каждому Worker при создании пула потоков канала. Мы знаем, что хотим использовать получатель в потоке, который экземпляры Worker порождают, так что мы сослаемся на параметр receiver в замыкании. Код в листинге 21-17 ещё не скомпилируется.

Filename: src/lib.rs
use std::{sync::mpsc, thread};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, receiver));
        }

        ThreadPool { workers, sender }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

// --snip--


struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker {
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}
Listing 21-17: Передача получателя каждому Worker

Мы внесли небольшие и простые изменения: передаём получатель в Worker::new, а затем используем его внутри замыкания.

Когда мы пытаемся проверить этот код, мы получаем эту ошибку:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0382]: use of moved value: `receiver`
  --> src/lib.rs:26:42
   |
21 |         let (sender, receiver) = mpsc::channel();
   |                      -------- move occurs because `receiver` has type `std::sync::mpsc::Receiver<Job>`, which does not implement the `Copy` trait
...
25 |         for id in 0..size {
   |         ----------------- inside of this loop
26 |             workers.push(Worker::new(id, receiver));
   |                                          ^^^^^^^^ value moved here, in previous iteration of loop
   |
note: consider changing this parameter type in method `new` to borrow instead if owning the value isn't necessary
  --> src/lib.rs:47:33
   |
47 |     fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker {
   |        --- in this method       ^^^^^^^^^^^^^^^^^^^ this parameter takes ownership of the value
help: consider moving the expression out of the loop so it is only moved once
   |
25 ~         let mut value = Worker::new(id, receiver);
26 ~         for id in 0..size {
27 ~             workers.push(value);
   |

For more information about this error, try `rustc --explain E0382`.
error: could not compile `hello` (lib) due to 1 previous error

Код пытается передать receiver нескольким экземплярам Worker. Это не сработает, как вы вспомните из главы 16: реализация канала, которую предоставляет Rust, — это несколько производителей, один потребитель. Это означает, что мы не можем просто клонировать потребительский конец канала, чтобы исправить этот код. Мы также не хотим отправлять сообщение несколько раз нескольким потребителям; мы хотим один список сообщений с несколькими экземплярами Worker так, чтобы каждое сообщение обрабатывалось один раз.

Кроме того, взятие задачи из очереди канала включает изменение receiver, поэтому потокам нужен безопасный способ разделения и изменения receiver; иначе мы можем получить гонки данных (как рассматривалось в главе 16).

Вспомните умные указатели с потокобезопасностью, рассмотренные в главе 16: чтобы разделить владение несколькими потоками и позволить потокам изменять значение, нам нужно использовать Arc<Mutex<T>>. Тип Arc позволит нескольким экземплярам Worker владеть получателем, а Mutex обеспечит, что только один Worker за раз получает задачу из получателя. Листинг 21-18 показывает изменения, которые нам нужно сделать.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};
// --snip--

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

// --snip--

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        // --snip--
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}
Listing 21-18: Разделение получателя среди экземпляров Worker с использованием Arc и Mutex

В ThreadPool::new мы помещаем получатель в Arc и Mutex. Для каждого нового Worker мы клонируем Arc, чтобы увеличить количество ссылок, так что экземпляры Worker могут разделять владение получателем.

С этими изменениями код компилируется! Мы приближаемся!

Реализация метода execute

Давайте наконец реализуем метод execute на ThreadPool. Мы также изменим Job со структуры на псевдоним типа для объекта типажа, который хранит тип замыкания, которое получает execute. Как обсуждалось в “Creating Type Synonyms with Type Aliases” в главе 20, псевдонимы типов позволяют нам делать длинные типы короче для удобства использования. Посмотрите на листинг 21-19.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

// --snip--

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

// --snip--

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}
Listing 21-19: Создание псевдонима типа Job для Box, хранящего каждое замыкание, а затем отправка задачи по каналу

После создания нового экземпляра Job с использованием замыкания, которое мы получаем в execute, мы отправляем эту задачу по отправляющему концу канала. Мы вызываем unwrap на send на случай, если отправка завершится неудачей. Это может произойти, например, если мы остановим все наши потоки от выполнения, что означает, что принимающий конец перестал получать новые сообщения. В данный момент мы не можем остановить наши потоки от выполнения: наши потоки продолжают выполняться, пока существует пул. Причина, по которой мы используем unwrap, заключается в том, что мы знаем, что случай неудачи не произойдёт, но компилятор не знает этого.

Но мы ещё не совсем закончили! В Worker наше замыкание, передаваемое в thread::spawn, всё ещё только ссылается на принимающий конец канала. Вместо этого нам нужно, чтобы замыкание бесконечно циклировало, запрашивая у принимающего конца канала задачу и выполняя задачу, когда она получена. Давайте внесём изменение, показанное в листинге 21-20, в Worker::new.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

// --snip--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
Listing 21-20: Получение и выполнение задач в потоке экземпляра Worker

Здесь мы сначала вызываем lock на receiver, чтобы получить мьютекс, а затем вызываем unwrap, чтобы вызвать панику при любых ошибках. Получение блокировки может завершиться неудачей, если мьютекс находится в отравленном состоянии, что может произойти, если какой-то другой поток вызвал панику, удерживая блокировку, а не освобождая её. В этой ситуации вызов unwrap, чтобы заставить этот поток вызвать панику, — правильное действие. Не стесняйтесь изменить этот unwrap на expect с сообщением об ошибке, которое имеет смысл для вас.

Если мы получаем блокировку на мьютексе, мы вызываем recv для получения Job из канала. Последний unwrap преодолевает любые ошибки здесь также, что может произойти, если поток, удерживающий отправителя, завершился, аналогично тому, как метод send возвращает Err, если получатель завершается.

Вызов recv блокируется, так что если задачи ещё нет, текущий поток будет ждать, пока задача не станет доступной. Mutex<T> обеспечивает, что только один поток Worker за раз пытается запросить задачу.

Наш пул потоков теперь в рабочем состоянии! Дайте ему cargo run и сделайте несколько запросов:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
warning: field `workers` is never read
 --> src/lib.rs:7:5
  |
6 | pub struct ThreadPool {
  |            ---------- field in this struct
7 |     workers: Vec<Worker>,
  |     ^^^^^^^
  |
  = note: `#[warn(dead_code)]` on by default

warning: fields `id` and `thread` are never read
  --> src/lib.rs:48:5
   |
47 | struct Worker {
   |        ------ fields in this struct
48 |     id: usize,
   |     ^^
49 |     thread: thread::JoinHandle<()>,
   |     ^^^^^^

warning: `hello` (lib) generated 2 warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.91s
     Running `target/debug/hello`
Worker 0 got a job; executing.
Worker 2 got a job; executing.
Worker 1 got a job; executing.
Worker 3 got a job; executing.
Worker 0 got a job; executing.
Worker 2 got a job; executing.
Worker 1 got a job; executing.
Worker 3 got a job; executing.
Worker 0 got a job; executing.
Worker 2 got a job; executing.

Успех! Теперь у нас есть пул потоков, который выполняет соединения асинхронно. Никогда не создаётся более четырёх потоков, так что наша система не будет перегружена, если сервер получает много запросов. Если мы делаем запрос к /sleep, сервер сможет обслуживать другие запросы, заставив другой поток выполнять их.

Примечание: Если вы откроете /sleep в нескольких окнах браузера одновременно, они могут загружаться по одному с интервалами в пять секунд. Некоторые веб-браузеры выполняют несколько экземпляров одного и того же запроса последовательно по причинам кэширования. Это ограничение не вызвано нашим веб-сервером.

Это хорошее время, чтобы остановиться и подумать, как код в листингах 21-18, 21-19 и 21-20 отличался бы, если бы мы использовали futures вместо замыкания для работы, которую нужно выполнить. Какие типы изменились? Как бы изменились сигнатуры методов, если бы они изменились? Какие части кода остались бы такими же?

После изучения цикла while let в главах 17 и 18 вы можете задаться вопросом, почему мы не написали код рабочего потока, как показано в листинге 21-21.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}
// --snip--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            while let Ok(job) = receiver.lock().unwrap().recv() {
                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
Listing 21-21: Альтернативная реализация Worker::new с использованием while let

Этот код компилируется и работает, но не приводит к желаемому поведению потоков: медленный запрос всё ещё заставит другие запросы ждать обработки. Причина несколько тонкая: у структуры Mutex нет публичного метода unlock, потому что владение блокировкой основано на времени жизни MutexGuard<T> внутри LockResult<MutexGuard<T>>, который возвращает метод lock. На этапе компиляции проверка заимствований может затем обеспечить правило, что ресурс, защищённый Mutex, не может быть доступен, если мы не удерживаем блокировку. Однако это также может привести к тому, что блокировка удерживается дольше, чем предполагалось, если мы не внимательны ко времени жизни MutexGuard<T>.

Код в листинге 21-20, который использует let job = receiver.lock().unwrap().recv().unwrap();, работает, потому что с let любые временные значения, используемые в выражении справа от знака равенства, немедленно удаляются, когда завершается оператор let. Однако while letif let, и match) не удаляет временные значения до конца связанного блока. В листинге 21-21 блокировка остаётся удерживаемой на время вызова job(), что означает, что другие экземпляры Worker не могут получать задачи.

Грациозное завершение и очистка

Код в Листинге 21-20 асинхронно обрабатывает запросы с помощью пула потоков, как и задумывалось. Мы получаем предупреждения о том, что поля workers, id и thread не используются напрямую, что напоминает нам об отсутствии очистки. Когда мы используем менее элегантный метод ctrl-c для остановки основного потока, все остальные потоки также немедленно останавливаются, даже если они находятся в процессе обработки запроса.

Далее мы реализуем типаж Drop, чтобы вызывать join для каждого потока в пуле, позволяя им завершить текущие задачи перед закрытием. Затем мы реализуем способ сообщить потокам, что они должны прекратить принимать новые задачи и завершить работу. Чтобы увидеть этот код в действии, мы модифицируем наш сервер так, чтобы он принимал только два запроса перед грациозным завершением пула потоков.

Одна деталь, на которую стоит обратить внимание: ничто из этого не затрагивает части кода, отвечающие за выполнение замыканий, поэтому всё здесь останется таким же, если бы мы использовали пул потоков для асинхронного рантайма.

Реализация типажа Drop для ThreadPool

Начнём с реализации Drop для нашего пула потоков. Когда пул удаляется, все наши потоки должны объединиться (join), чтобы убедиться, что они завершат свою работу. Листинг 21-22 показывает первую попытку реализации Drop; этот код пока не будет компилироваться.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in &mut self.workers {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
Listing 21-22: Объединение каждого потока при выходе пула потоков из области видимости

Сначала мы перебираем всех workers пула потоков. Мы используем &mut для этого, потому что self — изменяемая ссылка, и нам также нужно иметь возможность изменять worker. Для каждого рабочего мы выводим сообщение о том, что этот конкретный экземпляр Worker завершает работу, а затем вызываем join у потока этого экземпляра Worker. Если вызов join завершится неудачей, мы используем unwrap, чтобы вызвать панику Rust и перейти к неграциозному завершению.

Вот ошибка, которую мы получаем при компиляции этого кода:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0507]: cannot move out of `worker.thread` which is behind a mutable reference
  --> src/lib.rs:52:13
   |
52 |             worker.thread.join().unwrap();
   |             ^^^^^^^^^^^^^ ------ `worker.thread` moved due to this method call
   |             |
   |             move occurs because `worker.thread` has type `JoinHandle<()>`, which does not implement the `Copy` trait
   |
note: `JoinHandle::<T>::join` takes ownership of the receiver `self`, which moves `worker.thread`
  --> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/std/src/thread/mod.rs:1876:17

For more information about this error, try `rustc --explain E0507`.
error: could not compile `hello` (lib) due to 1 previous error

Ошибка сообщает, что мы не можем вызвать join, потому что у нас только изменяемая заимствованная ссылка на каждого worker, а join принимает владение своим аргументом. Чтобы решить эту проблему, нам нужно переместить поток из экземпляра Worker, который владеет thread, чтобы join мог потреблять поток. Один из способов сделать это — использовать тот же подход, что и в Листинге 18-15. Если бы Worker содержал Option<thread::JoinHandle<()>>, мы могли бы вызвать метод take у Option, чтобы переместить значение из варианта Some и оставить вариант None на его месте. Другими словами, Worker, который выполняется, имел бы вариант Some в thread, а когда мы захотели бы очистить Worker, мы заменили бы Some на None, чтобы у Worker не было потока для выполнения.

Однако, единственный раз, когда это потребуется, — это при удалении Worker. Взамен нам пришлось бы иметь дело с Option<thread::JoinHandle<()>> везде, где мы обращаемся к worker.thread. Идиоматический Rust часто использует Option, но когда вы обнаруживаете, что оборачиваете что-то, что, как вы знаете, всегда будет присутствовать, в Option как обходной путь, это хороший признак поиска альтернативных подходов. Они могут сделать ваш код чище и менее подверженным ошибкам.

В этом случае существует лучшая альтернатива: метод Vec::drain. Он принимает параметр диапазона для указания, какие элементы удалить из Vec, и возвращает итератор этих элементов. Передача синтаксиса диапазона .. удалит все значения из Vec.

Таким образом, нам нужно обновить реализацию drop для ThreadPool следующим образом:

Filename: src/lib.rs
#![allow(unused)]
fn main() {
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
}

Это решает ошибку компилятора и не требует никаких других изменений в нашем коде.

Сигнализация потокам о прекращении прослушивания задач

Со всеми внесёнными изменениями наш код компилируется без предупреждений. Однако плохая новость в том, что этот код пока не работает так, как мы хотим. Ключевой момент — логика в замыканиях, выполняемых потоками экземпляров Worker: в данный момент мы вызываем join, но это не остановит потоки, потому что они loop вечно в поисках задач. Если мы попытаемся удалить наш ThreadPool с текущей реализацией drop, основной поток будет блокироваться вечно, ожидая завершения первого потока.

Чтобы исправить эту проблему, нам потребуется изменение в реализации drop для ThreadPool и затем изменение в цикле Worker.

Сначала мы изменим реализацию drop для ThreadPool, чтобы явно удалить sender перед ожиданием завершения потоков. Листинг 21-23 показывает изменения в ThreadPool для явного удаления sender. В отличие от потока, здесь нам действительно нужно использовать Option, чтобы иметь возможность переместить sender из ThreadPool с помощью Option::take.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}
// --snip--

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        // --snip--

        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
Listing 21-23: Явное удаление sender перед объединением потоков Worker

Удаление sender закрывает канал, что указывает, что больше сообщений не будет отправлено. Когда это происходит, все вызовы recv, которые экземпляры Worker выполняют в бесконечном цикле, вернут ошибку. В Листинге 21-24 мы изменяем цикл Worker, чтобы грациозно выйти из цикла в этом случае, что означает, что потоки завершатся, когда реализация drop для ThreadPool вызовет join для них.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let message = receiver.lock().unwrap().recv();

                match message {
                    Ok(job) => {
                        println!("Worker {id} got a job; executing.");

                        job();
                    }
                    Err(_) => {
                        println!("Worker {id} disconnected; shutting down.");
                        break;
                    }
                }
            }
        });

        Worker { id, thread }
    }
}
Listing 21-24: Явный выход из цикла при возврате recv ошибки

Чтобы увидеть этот код в действии, давайте изменим main так, чтобы он принимал только два запроса перед грациозным завершением сервера, как показано в Листинге 21-25.

Filename: src/main.rs
use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }

    println!("Shutting down.");
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-25: Завершение сервера после обработки двух запросов путём выхода из цикла

Вам не хотелось бы, чтобы реальный веб-сервер завершал работу после обработки только двух запросов. Этот код просто демонстрирует, что грациозное завершение и очистка работают.

Метод take определён в типаже Iterator и ограничивает итерацию первыми двумя элементами максимум. ThreadPool выйдет из области видимости в конце main, и реализация drop будет выполнена.

Запустите сервер с помощью cargo run и сделайте три запроса. Третий запрос должен завершиться ошибкой, и в вашем терминале вы должны увидеть вывод, похожий на этот:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
     Running `target/debug/hello`
Worker 0 got a job; executing.
Shutting down.
Shutting down worker 0
Worker 3 got a job; executing.
Worker 1 disconnected; shutting down.
Worker 2 disconnected; shutting down.
Worker 3 disconnected; shutting down.
Worker 0 disconnected; shutting down.
Shutting down worker 1
Shutting down worker 2
Shutting down worker 3

Вы можете увидеть другой порядок идентификаторов Worker и выводимых сообщений. Мы можем понять, как работает этот код, по сообщениям: экземпляры Worker 0 и 3 получили первые два запроса. Сервер перестал принимать соединения после второго соединения, и реализация Drop на ThreadPool начала выполняться, прежде чем Worker 3 даже начал свою задачу. Удаление sender отключает все экземпляры Worker и сообщает им о завершении. Каждый экземпляр Worker выводит сообщение при отключении, а затем пул потоков вызывает join, чтобы дождаться завершения каждого потока Worker.

Обратите внимание на один интересный аспект этого конкретного выполнения: ThreadPool удалил sender, и прежде чем какой-либо Worker получил ошибку, мы попытались объединить Worker 0. Worker 0 ещё не получил ошибку от recv, поэтому основной поток блокировался, ожидая завершения Worker 0. Между тем Worker 3 получил задачу, а затем все потоки получили ошибку. Когда Worker 0 завершился, основной поток ждал завершения остальных экземпляров Worker. На тот момент они все вышли из своих циклов и остановились.

Поздравляем! Мы завершили наш проект; у нас есть базовый веб-сервер, который использует пул потоков для асинхронного ответа. Мы способны выполнить грациозное завершение сервера, которое очищает все потоки в пуле.

Вот полный код для справки:

Filename: src/main.rs
use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }

    println!("Shutting down.");
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in &mut self.workers {
            println!("Shutting down worker {}", worker.id);

            if let Some(thread) = worker.thread.take() {
                thread.join().unwrap();
            }
        }
    }
}

struct Worker {
    id: usize,
    thread: Option<thread::JoinHandle<()>>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let message = receiver.lock().unwrap().recv();

                match message {
                    Ok(job) => {
                        println!("Worker {id} got a job; executing.");

                        job();
                    }
                    Err(_) => {
                        println!("Worker {id} disconnected; shutting down.");
                        break;
                    }
                }
            }
        });

        Worker {
            id,
            thread: Some(thread),
        }
    }
}

Мы могли бы сделать больше! Если вы хотите продолжить улучшать этот проект, вот несколько идей:

  • Добавить больше документации к ThreadPool и его публичным методам.
  • Добавить тесты функциональности библиотеки.
  • Заменить вызовы unwrap на более надёжную обработку ошибок.
  • Использовать ThreadPool для выполнения задач, отличных от обслуживания веб-запросов.
  • Найти пул потоков на crates.io и реализовать аналогичный веб-сервер, используя крейт вместо нашего. Затем сравнить его API и надёжность с пулом потоков, который мы реализовали.

Заключение

Молодец! Вы дошли до конца книги! Мы хотим поблагодарить вас за это путешествие по Rust. Теперь вы готовы реализовывать свои собственные проекты на Rust и помогать в проектах других людей. Помните, что существует гостеприимное сообщество других растаянцев, которые с радостью помогут вам с любыми трудностями, которые вы встретите на своём пути в Rust.

Конец эксперимента

Спасибо за участие в нашем эксперименте! Надеемся, вы нашли что-то полезное в наших дополнениях. Ваше участие поможет сделать Rust лучше для всех.

Вы можете подписаться на @tonofcrates в Mastodon, если хотите узнавать о будущих обновлениях этого эксперимента.

Приложение

Следующие разделы содержат справочный материал, который может оказаться полезным в вашем пути Rust.

Приложение A: Ключевые слова

Следующий список содержит ключевые слова, зарезервированные для текущего или будущего использования языком Rust. Как таковые, они не могут использоваться в качестве идентификаторов (за исключением сырых идентификаторов, как мы обсудим в разделе «Сырые идентификаторы»). Идентификаторы — это имена функций, переменных, параметров, полей структур, модулей, крейтов, констант, макросов, статических значений, атрибутов, типов, типажей или времён жизни.

Ключевые слова, используемые в настоящее время

Ниже приведён список ключевых слов, используемых в настоящее время, с описанием их функциональности.

  • as — выполнение примитивного приведения типов, уточнение конкретного типажа, содержащего элемент, или переименование элементов в операторах use
  • async — возвращает Future вместо блокировки текущего потока
  • await — приостанавливает выполнение до готовности результата Future
  • break — немедленный выход из цикла
  • const — определение константных элементов или константных сырых указателей
  • continue — переход к следующей итерации цикла
  • crate — в пути модуля ссылается на корень крейта
  • dyn — динамическая диспетчеризация для объекта типажа
  • else — альтернатива для управляющих конструкций if и if let
  • enum — определение перечисления
  • extern — связывание внешней функции или переменной
  • false — булева константа ложь
  • fn — определение функции или типа указателя на функцию
  • for — перебор элементов из итератора, реализация типажа или указание времени жизни более высокого порядка
  • if — ветвление на основе результата условного выражения
  • impl — реализация собственной функциональности или функциональности типажа
  • in — часть синтаксиса цикла for
  • let — связывание переменной
  • loop — безусловный цикл
  • match — сопоставление значения с образцами
  • mod — определение модуля
  • move — заставляет замыкание принимать владение всеми своими захватами
  • mut — обозначает изменяемость в ссылках, сырых указателях или связываниях образцов
  • pub — обозначает видимость public в полях структур, блоках impl или модулях
  • ref — связывание по ссылке
  • return — возврат из функции
  • Self — псевдоним типа для определяемого или реализуемого типа
  • self — субъект метода или текущий модуль
  • static — глобальная переменная или время жизни, длящееся всё время выполнения программы
  • struct — определение структуры
  • super — родительский модуль текущего модуля
  • trait — определение типажа
  • true — булева константа истина
  • type — определение псевдонима типа или ассоциированного типа
  • union — определение объединения; является ключевым словом только при использовании в объявлении объединения
  • unsafe — обозначает небезопасный код, функции, типажи или реализации
  • use — вводит символы в область видимости; задаёт точные захваты для обобщений и ограничений времени жизни
  • where — обозначает предложения, ограничивающие тип
  • while — условный цикл на основе результата выражения

Ключевые слова, зарезервированные для будущего использования

Следующие ключевые слова пока не имеют никакой функциональности, но зарезервированы Rust для потенциального использования в будущем.

  • abstract
  • become
  • box
  • do
  • final
  • gen
  • macro
  • override
  • priv
  • try
  • typeof
  • unsized
  • virtual
  • yield

Сырые идентификаторы

Сырые идентификаторы — это синтаксис, позволяющий использовать ключевые слова там, где они обычно не разрешены. Вы используете сырой идентификатор, добавляя префикс r# перед ключевым словом.

Например, match — это ключевое слово. Если вы попытаетесь скомпилировать следующую функцию, использующую match в качестве имени:

Имя файла: src/main.rs

fn match(needle: &str, haystack: &str) -> bool {
    haystack.contains(needle)
}

вы получите следующую ошибку:

error: expected identifier, found keyword `match`
 --> src/main.rs:4:4
  |
4 | fn match(needle: &str, haystack: &str) -> bool {
  |    ^^^^^ expected identifier, found keyword

Ошибка показывает, что вы не можете использовать ключевое слово match в качестве имени функции. Чтобы использовать match в качестве имени функции, вам нужно использовать синтаксис сырого идентификатора, вот так:

Имя файла: src/main.rs

fn r#match(needle: &str, haystack: &str) -> bool {
    haystack.contains(needle)
}

fn main() {
    assert!(r#match("foo", "foobar"));
}

Этот код скомпилируется без каких-либо ошибок. Обратите внимание на префикс r# в имени функции в её определении, а также в месте вызова функции в main.

Сырые идентификаторы позволяют использовать любое слово по вашему выбору в качестве идентификатора, даже если это слово является зарезервированным ключевым словом. Это даёт нам больше свободы в выборе имён идентификаторов, а также позволяет интегрироваться с программами, написанными на языке, где эти слова не являются ключевыми. Кроме того, сырые идентификаторы позволяют использовать библиотеки, написанные на другой редакции Rust, чем использует ваш крейт. Например, try не является ключевым словом в редакции 2015, но является таковым в редакциях 2018, 2021 и 2024. Если вы зависите от библиотеки, написанной с использованием редакции 2015 и имеющей функцию try, вам нужно будет использовать синтаксис сырого идентификатора, r#try в данном случае, чтобы вызвать эту функцию из вашего кода в более поздних редакциях. См. Приложение E для получения дополнительной информации о редакциях.

Приложение B: Операторы и символы

В этом приложении содержится глоссарий синтаксиса Rust, включая операторы и другие символы, которые появляются самостоятельно или в контексте путей, обобщений, ограничений типажей, макросов, атрибутов, комментариев, кортежей и скобок.

Операторы

В таблице B-1 перечислены операторы Rust, пример использования оператора в контексте, краткое объяснение и возможность перегрузки. Если оператор можно перегрузить, указан соответствующий типаж для его перегрузки.

Таблица B-1: Операторы

ОператорПримерОбъяснениеМожно перегрузить?
!ident!(...), ident!{...}, ident![...]Расширение макроса
!!exprПобитовое или логическое отрицаниеNot
!=expr != exprСравнение на неравенствоPartialEq
%expr % exprАрифметический остатокRem
%=var %= exprАрифметический остаток и присваиваниеRemAssign
&&expr, &mut exprЗаимствование
&&type, &mut type, &'a type, &'a mut typeТип заимствованного указателя
&expr & exprПобитовое ИBitAnd
&=var &= exprПобитовое И и присваиваниеBitAndAssign
&&expr && exprЛогическое И с коротким замыканием
*expr * exprАрифметическое умножениеMul
*=var *= exprАрифметическое умножение и присваиваниеMulAssign
**exprРазыменованиеDeref
**const type, *mut typeСырой указатель
+trait + trait, 'a + traitСоставное ограничение типа
+expr + exprАрифметическое сложениеAdd
+=var += exprАрифметическое сложение и присваиваниеAddAssign
,expr, exprРазделитель аргументов и элементов
-- exprАрифметическое отрицаниеNeg
-expr - exprАрифметическое вычитаниеSub
-=var -= exprАрифметическое вычитание и присваиваниеSubAssign
->fn(...) -> type, |…| -> typeТип возвращаемого значения функции и замыкания
.expr.identДоступ к полю
.expr.ident(expr, ...)Вызов метода
.expr.0, expr.1, и т.д.Индексация кортежа
...., expr.., ..expr, expr..exprПравый эксклюзивный диапазон (литерал)PartialOrd
..=..=expr, expr..=exprПравый инклюзивный диапазон (литерал)PartialOrd
....exprСинтаксис обновления структуры (struct literal update syntax)
..variant(x, ..), struct_type { x, .. }Связывание образца «и остальное» («and the rest» pattern binding)
...expr...expr(Устарел, используйте ..=) В образце: инклюзивный диапазонный образец
/expr / exprАрифметическое делениеDiv
/=var /= exprАрифметическое деление и присваиваниеDivAssign
:pat: type, ident: typeОграничения
:ident: exprИнициализатор поля структуры
:'a: loop {...}Метка цикла
;expr;Завершитель выражения и элемента
;[...; len]Часть синтаксиса фиксированного массива
<<expr << exprСдвиг влевоShl
<<=var <<= exprСдвиг влево и присваиваниеShlAssign
<expr < exprСравнение «меньше»PartialOrd
<=expr <= exprСравнение «меньше или равно»PartialOrd
=var = expr, ident = typeПрисваивание/эквивалентность
==expr == exprСравнение на равенствоPartialEq
=>pat => exprЧасть синтаксиса ветки match (match arm syntax)
>expr > exprСравнение «больше»PartialOrd
>=expr >= exprСравнение «больше или равно»PartialOrd
>>expr >> exprСдвиг вправоShr
>>=var >>= exprСдвиг вправо и присваиваниеShrAssign
@ident @ patСвязывание в образце (pattern binding)
^expr ^ exprПобитовое исключающее ИЛИ (XOR)BitXor
^=var ^= exprПобитовое исключающее ИЛИ и присваиваниеBitXorAssign
|pat | patАльтернативы в образце (pattern alternatives)
|expr | exprПобитовое ИЛИBitOr
|=var |= exprПобитовое ИЛИ и присваиваниеBitOrAssign
||expr || exprЛогическое ИЛИ с коротким замыканием
?expr?Распространение ошибки (error propagation)

Неоператорные символы

Следующий список содержит все символы, которые не functioning как операторы; то есть они не ведут себя как вызов функции или метода.

Таблица B-2 показывает символы, которые появляются самостоятельно и допустимы в различных местах.

Таблица B-2: Самодостаточный синтаксис

СимволОбъяснение
'identИменованное время жизни или метка цикла
...u8, ...i32, ...f64, ...usize, и т.д.Числовой литерал с указанным типом
"..."Строковый литерал
r"...", r#"..."#, r##"..."##, и т.д.Сырой строковый литерал, escape-последовательности не обрабатываются
b"..."Байтовый строковый литерал; создаёт массив байтов вместо строки
br"...", br#"..."#, br##"..."##, и т.д.Сырой байтовый строковый литерал, комбинация сырого и байтового литерала
'...'Символьный литерал
b'...'ASCII-байтовый литерал
|…| exprЗамыкание
!Всегда пустой нижний тип (bottom type) для функций, которые никогда не возвращаются
_«Игнорируемое» связывание в образце; также используется для улучшения читаемости целочисленных литералов

Таблица B-3 показывает символы, которые появляются в контексте пути через иерархию модулей к элементу.

Таблица B-3: Синтаксис, связанный с путями

СимволОбъяснение
ident::identПуть в пространстве имён (namespace path)
::pathПуть относительно внешнего прелюда (extern prelude), где корневыми являются все остальные крейты (i.e., явно абсолютный путь, включающий имя крейта)
self::pathПуть относительно текущего модуля (i.e., явно относительный путь).
super::pathПуть относительно родительского модуля текущего модуля
type::ident, <type as trait>::identСвязанные константы, функции и типы
<type>::...Связанный элемент для типа, который нельзя назвать напрямую (например, <&T>::..., <[T]>::..., и т.д.)
trait::method(...)Уточнение вызова метода путём указания типажа, который его определяет
type::method(...)Уточнение вызова метода путём указания типа, для которого он определён
<type as trait>::method(...)Уточнение вызова метода путём указания типажа и типа

Таблица B-4 показывает символы, которые появляются в контексте использования обобщённых (generic) параметров типов.

Таблица B-4: Обобщения

СимволОбъяснение
path<...>Указывает параметры для обобщённого типа в типе (например, Vec<u8>)
path::<...>, method::<...>Указывает параметры для обобщённого типа, функции или метода в выражении; часто называется «турбо-рыба» (turbofish) (например, "42".parse::<i32>())
fn ident<...> ...Определяет обобщённую функцию
struct ident<...> ...Определяет обобщённую структуру
enum ident<...> ...Определяет обобщённое перечисление
impl<...> ...Определяет обобщённую реализацию (implementation)
for<...> typeОграничения времени жизни высшего порядка (higher-ranked lifetime bounds)
type<ident=type>Обобщённый тип, где один или несколько связанных типов имеют конкретные назначения (например, Iterator<Item=T>)

Таблица B-5 показывает символы, которые появляются в контексте ограничения обобщённых параметров типов с помощью ограничений типажей (trait bounds).

Таблица B-5: Ограничения типажей

СимволОбъяснение
T: UОбобщённый параметр T ограничен типами, которые реализуют U
T: 'aОбобщённый тип T должен переживать время жизни 'a (означает, что тип не может транзитивно содержать ссылки с временами жизни короче 'a)
T: 'staticОбобщённый тип T не содержит заимствованных ссылок, кроме тех, что со временем жизни 'static
'b: 'aОбобщённое время жизни 'b должно переживать время жизни 'a
T: ?SizedРазрешить обобщённому параметру типа быть динамически размещённым типом (dynamically sized type)
'a + trait, trait + traitСоставное ограничение типа

Таблица B-6 показывает символы, которые появляются в контексте вызова или определения макросов и указания атрибутов на элементе.

Таблица B-6: Макросы и атрибуты

СимволОбъяснение
#[meta]Внешний атрибут
#![meta]Внутренний атрибут
$identПодстановка макроса
$ident:kindЗахват макроса
$(…)…Повторение макроса
ident!(...), ident!{...}, ident![...]Вызов макроса

Таблица B-7 показывает символы, создающие комментарии.

Таблица B-7: Комментарии

СимволОбъяснение
//Однострочный комментарий
//!Внутренний документационный однострочный комментарий
///Внешний документационный однострочный комментарий
/*...*/Блочный комментарий
/*!...*/Внутренний документационный блочный комментарий
/**...*/Внешний документационный блочный комментарий

Таблица B-8 показывает контексты, в которых используются круглые скобки.

Таблица B-8: Круглые скобки

СимволОбъяснение
()Пустой кортеж (unit), как литерал и тип
(expr)Выражение в скобках
(expr,)Выражение одноэлементного кортежа
(type,)Тип одноэлементного кортежа
(expr, ...)Выражение кортежа
(type, ...)Тип кортежа
expr(expr, ...)Вызов функции; также используется для инициализации кортежевых структур и вариантов перечислений

Таблица B-9 показывает контексты, в которых используются фигурные скобки.

Таблица B-9: Фигурные скобки

КонтекстОбъяснение
{...}Блочное выражение
Type {...}Литерал struct

Таблица B-10 показывает контексты, в которых используются квадратные скобки.

Таблица B-10: Квадратные скобки

КонтекстОбъяснение
[...]Литерал массива
[expr; len]Литерал массива, содержащий len копий expr
[type; len]Тип массива, содержащий len экземпляров type
expr[expr]Индексация коллекции. Можно перегрузить (Index, IndexMut)
expr[..], expr[a..], expr[..b], expr[a..b]Индексация коллекции, имитирующая срез коллекции, с использованием Range, RangeFrom, RangeTo или RangeFull в качестве «индекса»

Приложение C: Типажи, для которых можно использовать derive

В разных местах книги мы обсуждали атрибут derive, который можно применять к определению структуры или перечисления. Атрибут derive генерирует код, реализующий типаж с использованием его стандартной реализации для типа, который вы пометили с помощью синтаксиса derive.

В этом приложении мы приводим справочник по всем типажам стандартной библиотеки, которые можно использовать с derive. Каждый раздел охватывает:

  • Какие операторы и методы станут доступны при использовании этого типажа
  • Что делает реализация типажа, предоставляемая derive
  • Что означает реализация типажа для данного типа
  • Условия, при которых реализация типажа разрешена или запрещена
  • Примеры операций, требующих наличия типажа

Если вам нужно поведение, отличное от того, которое предоставляет атрибут derive, обратитесь к документации стандартной библиотеки для каждого типажа, чтобы узнать, как реализовать его вручную.

Перечисленные здесь типажи — единственные, определённые стандартной библиотекой, которые можно реализовать для ваших типов с помощью derive. Другие типажи, определённые в стандартной библиотеке, не имеют разумного поведения по умолчанию, поэтому реализация их зависит от вас и должна соответствовать вашим задачам.

Примером типажа, который нельзя получить через derive, является Display, который отвечает за форматирование для конечных пользователей. Вы всегда должны задумываться о подходящем способе отображения типа для конечного пользователя. Какие части типа пользователь должен видеть? Какие части будут для него релевантны? В каком формате данные будут наиболее полезны? У компилятора Rust нет такого понимания, поэтому он не может предоставить вам подходящее поведение по умолчанию.

Список типажей, которые можно получить через derive в этом приложении, не является исчерпывающим: библиотеки могут реализовывать derive для своих собственных типажей, поэтому список типажей, с которыми можно использовать derive, по сути открыт. Реализация derive предполагает использование процедурного макроса, который рассматривается в разделе “Макросы” главы 20.

Debug для вывода, предназначенного для программистов

Типаж Debug включает форматирование для отладки в строках формата, которое указывается добавлением :? внутри заполнителей {}.

Типаж Debug позволяет выводить экземпляры типа для целей отладки, чтобы вы и другие программисты, использующие ваш тип, могли inspecting экземпляр в определённой точке выполнения программы.

Типаж Debug требуется, например, при использовании макроса assert_eq!. Этот макрос выводит значения экземпляров, переданных в качестве аргументов, если проверка на равенство завершилась неудачей, чтобы программисты могли понять, почему два экземпляра не равны.

PartialEq и Eq для сравнений на равенство

Типаж PartialEq позволяет сравнивать экземпляры типа для проверки на равенство и включает использование операторов == и !=.

Получение PartialEq через derive реализует метод eq. Когда PartialEq применяется к структурам, два экземпляра равны только если все поля равны, и экземпляры не равны, если какие-либо поля не равны. При применении к перечислениям каждый вариант равен сам себе и не равен другим вариантам.

Типаж PartialEq требуется, например, при использовании макроса assert_eq!, которому нужно уметь сравнивать два экземпляра типа на равенство.

Типаж Eq не имеет методов. Его цель — сигнализировать, что для любого значения аннотированного типа это значение равно самому себе. Типаж Eq можно применять только к типам, которые также реализуют PartialEq, хотя не все типы, реализующие PartialEq, могут реализовать Eq. Одним из примеров являются типы чисел с плавающей запятой: реализация чисел с плавающей запятой гласит, что два экземпляра значения «не число» (NaN) не равны друг другу.

Примером, когда требуется Eq, являются ключи в HashMap<K, V>, чтобы HashMap<K, V> мог определять, одинаковы ли два ключа.

PartialOrd и Ord для сравнений на порядок

Типаж PartialOrd позволяет сравнивать экземпляры типа для целей сортировки. Тип, реализующий PartialOrd, можно использовать с операторами <, >, <= и >=. Типаж PartialOrd можно применять только к типам, которые также реализуют PartialEq.

Получение PartialOrd через derive реализует метод partial_cmp, который возвращает Option<Ordering>, который будет None, когда переданные значения не образуют порядок. Примером значения, не образующего порядок, даже если большинство значений этого типа можно сравнить, является значение «не число» (NaN) для чисел с плавающей запятой. Вызов partial_cmp с любым числом с плавающей запятой и значением NaN для чисел с плавающей запятой вернёт None.

При применении к структурам PartialOrd сравнивает два экземпляра, сравнивая значение каждого поля в порядке, в котором поля определены в структуре. При применении к перечислениям варианты перечисления, объявленные раньше в определении перечисления, считаются меньшими, чем варианты, перечисленные позже.

Типаж PartialOrd требуется, например, для метода gen_range из крейта rand, который генерирует случайное значение в диапазоне, заданном выражением диапазона.

Типаж Ord позволяет знать, что для любых двух значений аннотированного типа всегда существует допустимый порядок. Типаж Ord реализует метод cmp, который возвращает Ordering вместо Option<Ordering>, поскольку допустимый порядок всегда возможен. Типаж Ord можно применять только к типам, которые также реализуют PartialOrd и EqEq требует PartialEq). При получении через derive для структур и перечислений cmp ведёт себя так же, как реализация partial_cmp для PartialOrd.

Примером, когда требуется Ord, является хранение значений в BTreeSet<T>, структуре данных, которая хранит данные на основе порядка сортировки значений.

Clone и Copy для дублирования значений

Типаж Clone позволяет явно создавать глубокую копию значения, и процесс дублирования может включать выполнение произвольного кода и копирование данных в куче. Подробнее о Clone см. в разделе “Переменные и данные: взаимодействие с Clone в главе 4.

Получение Clone через derive реализует метод clone, который при реализации для всего типа вызывает clone для каждой части типа. Это означает, что все поля или значения в типе также должны реализовывать Clone, чтобы можно было получить Clone через derive.

Примером, когда требуется Clone, является вызов метода to_vec на срезе. Срез не владеет экземплярами типа, которые он содержит, но вектор, возвращаемый из to_vec, должен владеть своими экземплярами, поэтому to_vec вызывает clone для каждого элемента. Следовательно, тип, хранящийся в срезе, должен реализовывать Clone.

Типаж Copy позволяет дублировать значение путём простого копирования битов, хранящихся в стеке; произвольный код не требуется. Подробнее о Copy см. в разделе “Данные только в стеке: Copy в главе 4.

Типаж Copy не определяет никаких методов, чтобы предотвратить перегрузку этих методов программистами и нарушение предположения о том, что не выполняется произвольный код. Таким образом, все программисты могут предполагать, что копирование значения будет очень быстрым.

Типаж Copy можно получить через derive для любого типа, все части которого реализуют Copy. Тип, реализующий Copy, должен также реализовывать Clone, поскольку тип, реализующий Copy, имеет тривиальную реализацию Clone, которая выполняет ту же задачу, что и Copy.

Типаж Copy требуется редко; типы, реализующие Copy, имеют доступные оптимизации, что означает, что вам не нужно вызывать clone, что делает код более лаконичным.

Всё, что возможно с Copy, можно также выполнить с Clone, но код может быть медленнее или должен использовать clone в определённых местах.

Hash для отображения значения в значение фиксированного размера

Типаж Hash позволяет взять экземпляр типа произвольного размера и отобразить этот экземпляр в значение фиксированного размера с помощью хэш-функции. Получение Hash через derive реализует метод hash. Реализация метода hash, полученная через derive, комбинирует результат вызова hash для каждой части типа, что означает, что все поля или значения должны также реализовывать Hash, чтобы можно было получить Hash через derive.

Примером, когда требуется Hash, является хранение ключей в HashMap<K, V> для эффективного хранения данных.

Default для значений по умолчанию

Типаж Default позволяет создать значение по умолчанию для типа. Получение Default через derive реализует функцию default. Реализация функции default, полученная через derive, вызывает функцию default для каждой части типа, что означает, что все поля или значения в типе должны также реализовывать Default, чтобы можно было получить Default через derive.

Функция Default::default обычно используется в сочетании с синтаксисом обновления структуры, рассмотренным в разделе “Создание экземпляров из других экземпляров с помощью синтаксиса обновления структуры” в главе 5. Вы можете настроить несколько полей структуры, а затем установить и использовать значение по умолчанию для остальных полей, используя ..Default::default().

Типаж Default требуется, когда вы используете метод unwrap_or_default для экземпляров Option<T>, например. Если Option<T> равен None, метод unwrap_or_default вернёт результат Default::default для типа T, хранящегося в Option<T>.

Приложение D - Полезные инструменты разработки

В этом приложении мы рассмотрим некоторые полезные инструменты разработки, которые предоставляет проект Rust. Мы посмотрим на автоматическое форматирование, быстрые способы применения исправлений предупреждений, линтер и интеграцию с IDE.

Автоматическое форматирование с помощью rustfmt

Инструмент rustfmt переформатирует ваш код в соответствии с общепринятым стилем кода. Многие совместные проекты используют rustfmt, чтобы избежать споров о том, какой стиль использовать при написании кода на Rust: каждый форматирует свой код с помощью этого инструмента.

Установки Rust по умолчанию включают rustfmt, поэтому у вас уже должны быть программы rustfmt и cargo-fmt. Эти две команды аналогичны rustc и cargo в том смысле, что rustfmt позволяет более детальный контроль, а cargo-fmt понимает соглашения проекта, использующего Cargo. Чтобы отформатировать любой проект Cargo, введите следующее:

$ cargo fmt

Запуск этой команды переформатирует весь код Rust в текущем крейте. Это должно изменить только стиль кода, а не его семантику.

Эта команда даёт вам rustfmt и cargo-fmt, аналогично тому, как Rust даёт вам и rustc, и cargo. Чтобы отформатировать любой проект Cargo, введите следующее:

$ cargo fmt

Запуск этой команды переформатирует весь код Rust в текущем крейте. Это должно изменить только стиль кода, а не его семантику. Для получения дополнительной информации о rustfmt, см. его документацию.

Исправляйте код с помощью rustfix

Инструмент rustfix включён в установки Rust и может автоматически исправлять предупреждения компилятора, у которых есть очевидный способ исправить проблему, который, скорее всего, именно то, что вам нужно. Вы, вероятно, уже видели предупреждения компилятора. Например, рассмотрим этот код:

Имя файла: src/main.rs

fn main() {
    let mut x = 42;
    println!("{x}");
}

Здесь мы определяем переменную x как изменяемую, но никогда не изменяем её на самом деле. Rust предупреждает нас об этом:

$ cargo build
   Compiling myprogram v0.1.0 (file:///projects/myprogram)
warning: variable does not need to be mutable
 --> src/main.rs:2:9
  |
2 |     let mut x = 0;
  |         ----^
  |         |
  |         help: remove this `mut`
  |
  = note: `#[warn(unused_mut)]` on by default

Предупреждение предлагает удалить ключевое слово mut. Мы можем автоматически применить это предложение, используя инструмент rustfix, запустив команду cargo fix:

$ cargo fix
    Checking myprogram v0.1.0 (file:///projects/myprogram)
      Fixing src/main.rs (1 fix)
    Finished dev [unoptimized + debuginfo] target(s) in 0.59s

Когда мы снова посмотрим на src/main.rs, мы увидим, что cargo fix изменил код:

Имя файла: src/main.rs

fn main() {
    let x = 42;
    println!("{x}");
}

Переменная x теперь неизменяема, и предупреждение больше не появляется.

Вы также можете использовать команду cargo fix для перехода вашего кода между разными редакциями Rust. Редакции рассматриваются в Приложении E.

Больше линтов с Clippy

Инструмент Clippy — это коллекция линтов для анализа вашего кода, чтобы вы могли отлавливать распространённые ошибки и улучшать ваш код на Rust. Clippy включён в стандартные установки Rust.

Чтобы запустить линты Clippy на любом проекте Cargo, введите следующее:

$ cargo clippy

Например, представьте, что вы написали программу, которая использует приближение математической константы, такой как пи, как это делает эта программа:

Filename: src/main.rs
fn main() {
    let x = 3.1415;
    let r = 8.0;
    println!("the area of the circle is {}", x * r * r);
}

Запуск cargo clippy на этом проекте приводит к этой ошибке:

error: approximate value of `f{32, 64}::consts::PI` found
 --> src/main.rs:2:13
  |
2 |     let x = 3.1415;
  |             ^^^^^^
  |
  = note: `#[deny(clippy::approx_constant)]` on by default
  = help: consider using the constant directly
  = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#approx_constant

Эта ошибка сообщает вам, что в Rust уже определена более точная константа PI, и ваша программа была бы более корректной, если бы вы использовали эту константу. Затем вы измените свой код, чтобы использовать константу PI. Следующий код не приводит к ошибкам или предупреждениям от Clippy:

Filename: src/main.rs
fn main() {
    let x = std::f64::consts::PI;
    let r = 8.0;
    println!("the area of the circle is {}", x * r * r);
}

Для получения дополнительной информации о Clippy, см. его документацию.

Интеграция с IDE с помощью rust-analyzer

Для помощи в интеграции с IDE сообщество Rust рекомендует использовать rust-analyzer. Этот инструмент — это набор утилит, ориентированных на компилятор, который говорит на Language Server Protocol, что представляет собой спецификацию для взаимодействия IDE и языков программирования. Разные клиенты могут использовать rust-analyzer, например плагин Rust analyzer для Visual Studio Code.

Посетите домашнюю страницу проекта rust-analyzer для инструкций по установке, затем установите поддержку language server в вашу конкретную IDE. Ваша IDE получит такие возможности, как автодополнение, переход к определению и встроенные ошибки.

Приложение E — Издания

В Главе 1 вы видели, что cargo new добавляет в ваш файл Cargo.toml метаданные об издании. Это приложение объясняет, что это значит!

Язык Rust и компилятор имеют шестинедельный цикл выпуска, что означает постоянный поток новых функций для пользователей. Другие языки программирования выпускают крупные изменения реже; Rust выпускает более мелкие обновления чаще. Со временем все эти мелкие изменения складываются воедино. Но от выпуска к выпуску может быть трудно оглянуться назад и сказать: «Вау, между Rust 1.10 и Rust 1.31 Rust сильно изменился!»

Примерно каждые три года команда Rust выпускает новое издание Rust. Каждое издание объединяет появившиеся функции в чёткий пакет с полностью обновлённой документацией и инструментарием. Новые издания поставляются в рамках обычного шестинедельного процесса выпуска.

Издания служат разным целям для разных людей:

  • Для активных пользователей Rust новое издание объединяет постепенные изменения в легко понятный пакет.
  • Для тех, кто не использует Rust, новое издание сигнализирует, что произошли некоторые крупные улучшения, которые могут сделать Rust достойным повторного внимания.
  • Для разработчиков Rust новое издание предоставляет объединяющую идею для всего проекта.

На момент написания доступны четыре издания Rust: Rust 2015, Rust 2018, Rust 2021 и Rust 2024. Эта книга написана с использованием идиом издания Rust 2024.

Ключ edition в Cargo.toml указывает, какое издание компилятор должен использовать для вашего кода. Если ключ не существует, Rust использует значение 2015 для издания по соображениям обратной совместимости.

Каждый проект может перейти на издание, отличное от стандартного издания 2015. Издания могут содержать несовместимые изменения, например, добавление нового ключевого слова, конфликтующего с идентификаторами в коде. Однако, если вы не перейдёте на эти изменения, ваш код будет продолжать компилироваться даже при обновлении используемой вами версии компилятора Rust.

Все версии компилятора Rust поддерживают любое издание, существовавшее до выпуска этого компилятора, и могут компоновать крейты любых поддерживаемых изданий вместе. Изменения изданий влияют только на способ первоначального разбора кода компилятором. Поэтому, если вы используете Rust 2015, а одна из ваших зависимостей использует Rust 2018, ваш проект скомпилируется и сможет использовать эту зависимость. Обратная ситуация, когда ваш проект использует Rust 2018, а зависимость — Rust 2015, также работает.

Чтобы было ясно: большинство функций будут доступны во всех изданиях. Разработчики, использующие любое издание Rust, будут продолжать видеть улучшения с каждым новым стабильным выпуском. Однако в некоторых случаях, в основном при добавлении новых ключевых слов, некоторые новые функции могут быть доступны только в более поздних изданиях. Вам потребуется перейти на новое издание, если вы хотите воспользоваться такими функциями.

Для получения более подробной информации Руководство по изданиям — это полная книга об изданиях, которая перечисляет различия между ними и объясняет, как автоматически обновить ваш код до нового издания с помощью cargo fix.

Приложение F: Переводы книги

Ресурсы на языках, отличных от английского. Большинство всё ещё в работе; смотрите метку «Переводы», чтобы помочь или сообщить о новом переводе!

Приложение G — Как создаётся Rust и «Nightly Rust»

Это приложение рассказывает о том, как создаётся Rust и как это влияет на вас как на разработчика на Rust.

Стабильность без застоя

Как язык, Rust очень заботится о стабильности вашего кода. Мы хотим, чтобы Rust был надёжным фундаментом, на котором можно строить, и если бы всё постоянно менялось, это было бы невозможно. В то же время, если мы не можем экспериментировать с новыми возможностями, мы можем не обнаружить важные недостатки до их выпуска, когда уже ничего нельзя изменить.

Наше решение этой проблемы — то, что мы называем «стабильностью без застоя», и наш руководящий принцип таков: вы никогда не должны бояться обновления до новой версии стабильного Rust. Каждое обновление должно быть безболезненным, но также должно приносить новые возможности, меньше ошибок и более быстрое время компиляции.

Чух-чух! Каналы выпуска и поезда

Разработка Rust следует графику поездов. То есть вся разработка ведётся в ветке master репозитория Rust. Выпуски следуют модели поездов выпуска программного обеспечения, которая использовалась в Cisco IOS и других проектах. Существует три канала выпуска для Rust:

  • Nightly
  • Beta
  • Stable

Большинство разработчиков Rust в основном используют стабильный канал, но те, кто хочет попробовать экспериментальные новые возможности, могут использовать nightly или beta.

Вот пример того, как работает процесс разработки и выпуска: предположим, что команда Rust работает над выпуском Rust 1.5. Этот выпуск состоялся в декабре 2015 года, но он даст нам реалистичные номера версий. Новая функциональность добавляется в Rust: новый коммит попадает в ветку master. Каждую ночь производится новая nightly-версия Rust. Каждый день — день выпуска, и эти выпуски создаются нашей инфраструктурой выпусков автоматически. Так что с течением времени наши выпуски выглядят так, раз в ночь:

nightly: * - - * - - *

Каждые шесть недель наступает время подготовить новый выпуск! Ветка beta репозитория Rust отделяется от ветки master, используемой nightly. Теперь есть два выпуска:

nightly: * - - * - - *
                     |
beta:                *

Большинство пользователей Rust не используют beta-выпуски активно, но тестируют на beta в своей системе CI, чтобы помочь Rust обнаружить возможные регрессии. В это время nightly-выпуск всё ещё происходит каждую ночь:

nightly: * - - * - - * - - * - - *
                     |
beta:                *

Предположим, регрессия обнаружена. Хорошо, что у нас было время протестировать beta-выпуск, прежде чем регрессия проникла в стабильный выпуск! Исправление применяется к master, так что nightly исправлен, а затем исправление переносится в ветку beta, и производится новый beta-выпуск:

nightly: * - - * - - * - - * - - * - - *
                     |
beta:                * - - - - - - - - *

Через шесть недель после создания первой beta наступает время стабильного выпуска! Ветка stable производится из ветки beta:

nightly: * - - * - - * - - * - - * - - * - * - *
                     |
beta:                * - - - - - - - - *
                                       |
stable:                                *

Ура! Rust 1.5 готов! Однако мы забыли об одном: поскольку прошло шесть недель, нам также нужна новая beta следующей версии Rust, 1.6. Так что после того, как stable отделяется от beta, следующая версия beta снова отделяется от nightly:

nightly: * - - * - - * - - * - - * - - * - * - *
                     |                         |
beta:                * - - - - - - - - *       *
                                       |
stable:                                *

Это называется «моделью поездов», потому что каждые шесть недель выпуск «отправляется со станции», но всё ещё должен пройти через канал beta, прежде чем стать стабильным выпуском.

Rust выпускается каждые шесть недель, как часы. Если вы знаете дату одного выпуска Rust, вы можете знать дату следующего: через шесть недель. Приятный аспект выпусков, запланированных каждые шесть недель, в том, что следующий поезд скоро приходит. Если функциональность пропускает particular выпуск, не нужно беспокоиться: следующий произойдёт в короткое время! Это помогает снизить давление, чтобы втиснуть возможно неотполированные функциональности вблизи срока выпуска.

Благодаря этому процессу вы всегда можете проверить следующую сборку Rust и убедиться, что обновление до неё простое: если beta-выпуск не работает как ожидается, вы можете сообщить об этом команде и исправить до следующего стабильного выпуска! Поломки в beta-выпуске относительно редки, но rustc всё ещё является программным обеспечением, и ошибки существуют.

Период поддержки

Проект Rust поддерживает последнюю стабильную версию. Когда выпускается новая стабильная версия, старая версия достигает конца срока поддержки (EOL). Это означает, что каждая версия поддерживается шесть недель.

Нестабильные функциональности

С этой моделью выпуска есть ещё одна особенность: нестабильные функциональности. Rust использует технику под названием «флаги функциональности», чтобы определить, какие функциональности включены в данном выпуске. Если новая функциональность находится в активной разработке, она попадает в master, а следовательно, в nightly, но за флагом функциональности. Если вы, как пользователь, хотите попробовать функциональность в разработке, вы можете, но вы должны использовать nightly-выпуск Rust и аннотировать ваш исходный код соответствующим флагом, чтобы присоединиться.

Если вы используете beta или стабильный выпуск Rust, вы не можете использовать никакие флаги функциональности. Это ключ, который позволяет нам получать практическое использование новых функциональностей, прежде чем объявить их стабильными навсегда. Те, кто хочет присоединиться к переднему краю, могут это сделать, а те, кто хочет надёжного опыта, могут придерживаться стабильного и знать, что их код не сломается. Стабильность без застоя.

Эта книга содержит только информацию о стабильных функциональностях, поскольку функциональности в разработке всё ещё меняются, и они, безусловно, будут отличаться между написанием этой книги и их включением в стабильные сборки. Документацию по функциональностям только для nightly можно найти онлайн.

Rustup и роль Rust Nightly

Rustup упрощает переключение между разными каналами выпуска Rust, глобально или для каждого проекта. По умолчанию у вас будет установлен стабильный Rust. Чтобы установить nightly, например:

$ rustup toolchain install nightly

Вы также можете увидеть все наборы инструментов (выпуски Rust и связанные компоненты), которые у вас установлены с помощью rustup. Вот пример на компьютере одного из авторов под Windows:

> rustup toolchain list
stable-x86_64-pc-windows-msvc (default)
beta-x86_64-pc-windows-msvc
nightly-x86_64-pc-windows-msvc

Как видите, стабильный набор инструментов — это по умолчанию. Большинство пользователей Rust используют стабильный большую часть времени. Вы можете захотеть использовать стабильный большую часть времени, но использовать nightly в конкретном проекте, потому что вас интересует передовая функциональность. Чтобы это сделать, вы можете использовать rustup override в каталоге этого проекта, чтобы установить nightly-набор инструментов как тот, который rustup должен использовать, когда вы находитесь в этом каталоге:

$ cd ~/projects/needs-nightly
$ rustup override set nightly

Теперь каждый раз, когда вы вызываете rustc или cargo внутри ~/projects/needs-nightly, rustup убедится, что вы используете nightly Rust, а не стабильный Rust по умолчанию. Это пригодится, когда у вас много проектов на Rust!

Процесс RFC и команды

Так как же вы узнаёте об этих новых функциональностях? Модель разработки Rust следует процессу запроса комментариев (RFC). Если вы хотите улучшения в Rust, вы можете написать предложение, называемое RFC.

Любой может писать RFC для улучшения Rust, и предложения рассматриваются и обсуждаются командой Rust, которая состоит из многих тематических подкоманд. Полный список команд on Rust’s website, который включает команды для каждой области проекта: дизайн языка, реализация компилятора, инфраструктура, документация и другие. Соответствующая команда читает предложение и комментарии, пишет свои собственные комментарии, и в конце концов достигается консенсус по принятию или отклонению функциональности.

Если функциональность принята, issue открывается в репозитории Rust, и кто-то может её реализовать. Тот, кто реализует её, очень хорошо может не быть тем, кто предложил функциональность изначально! Когда реализация готова, она попадает в ветку master за флагом функциональности, как мы обсуждали в разделе “Нестабильные функциональности”.

Через некоторое время, как только разработчики Rust, использующие nightly-выпуски, смогут попробовать новую функциональность, члены команды обсудят функциональность, как она работает на nightly, и решат, должна ли она попасть в стабильный Rust или нет. Если решение — двигаться вперёд, флаг функциональности удаляется, и функциональность теперь считается стабильной! Она едет на поездах в новый стабильный выпуск Rust.