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

Добро пожаловать в Comprehensive Rust 🦀

Build workflow GitHub contributors GitHub stars

Это бесплатный курс по Rust, разработанный командой Android в Google. Курс охватывает весь спектр Rust: от базового синтаксиса до сложных тем, таких как обобщённые типы (generics) и обработка ошибок.

Актуальная версия курса доступна по адресу https://r007.github.io/EmbeddedRust-Rus/comprehensive-rust/. Если вы читаете курс в другом месте, пожалуйста, проверьте обновления там.

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

Курс также доступен в формате EPUB.

Цель курса — научить вас языку Rust. Мы предполагаем, что вы ничего не знаете о Rust, и надеемся:

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

Первые четыре дня курса мы называем «Основы Rust» (Rust Fundamentals).

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

  • Android: рассчитанный на полдня курс по использованию Rust для разработки на платформе Android (AOSP). Сюда входит взаимодействие с C, C++ и Java.
  • Chromium: рассчитанный на полдня курс по использованию Rust в браузерах на базе Chromium. Включает взаимодействие с C++ и подключение сторонних крейтов в Chromium.
  • Bare-metal: занятие на целый день по использованию Rust для разработки bare-metal (встраиваемых систем). Рассматриваются как микроконтроллеры, так и прикладные процессоры.
  • Конкурентность: занятие на целый день, посвящённое конкурентности в Rust. Мы рассмотрим как классическую конкурентность (вытесняющее планирование с помощью потоков и мьютексов), так и конкурентность на основе async/await (кооперативная многозадачность с помощью фьючерсов).

За рамками курса

Rust — это объёмный язык, и мы не сможем охватить его целиком за несколько дней. Темы, выходящие за рамки курса:

Предположения

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

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

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

Проведение курса

Эта страница предназначена для преподавателя курса.

Вот краткая справочная информация о том, как мы проводили курс внутри компании Google.

Обычно занятия проходят с 9:00 до 16:00 с часовым перерывом на обед. Таким образом, на утреннее и на дневное занятие остается по 3 часа. В каждой части есть несколько перерывов и время, которое слушатели тратят на упражнения.

Перед проведением курса желательно:

  1. Ознакомиться с материалами курса. Мы добавили заметки для преподавателя на некоторых страницах, чтобы выделить ключевые моменты (пожалуйста, помогайте нам, добавляя свои заметки для преподавателей!). Убедитесь в том, что открыли заметки для преподавателя во всплывающем окне (нажмите на ссылку с маленькой стрелкой рядом с «Заметки для преподавателя»). У вас откроется отдельное окно с заметками для преподавателя, в то время как основное окно вы можете демонстрировать классу.

  2. Определите даты. Поскольку курс занимает четыре дня, мы рекомендуем распределить эти дни на две недели. Участники курса говорят, что им полезен перерыв посередине, так как он помогает переварить всю информацию, которую мы им даём.

  3. Найдите помещение, достаточно просторное для очных участников. Мы рекомендуем группу из 15–25 человек. Это достаточно мало, чтобы людям было удобно задавать вопросы, и в то же время достаточно, чтобы один преподаватель успевал на них отвечать. Убедитесь, что в помещении есть столы для вас и для слушателей: всем нужно сидеть и работать с ноутбуками. Вам как преподавателю придется много писать код на занятии вживую, поэтому кафедра будет мало полезна.

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

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

На этом всё, удачи в прохождении курса! Мы надеемся, что вам будет так же весело, как и нам!

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

Подготовка преподавателя

  • Изучите весь материал: перед проведением курса просмотрите все слайды и упражнения сами. Это поможет предугадать вопросы и возможные трудности.
  • Подготовьтесь к живому кодингу: курс предполагает много работы с живым кодом. Заранее отработайте примеры и упражнения, чтобы уверенно набирать их на занятии. Держите решения под рукой на случай, если застрянете.
  • Освойтесь с mdbook: курс представлен с помощью mdbook. Знание навигации, поиска и других возможностей сделает презентацию более плавной.
  • Подсказка по размеру слайда: нажмите Ctrl + Alt + B, чтобы включить визуальную подсказку с доступным при презентации пространством. Сначала содержимое за пределами красной рамки будет скрыто. Используйте эту подсказку при редактировании слайдов. Также можно включить её по этой ссылке.

Создание хорошей среды для обучения

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

Структура курса

Эта страница предназначена для преподавателя курса.

Основы Rust

Первые четыре дня составляют Основы Rust. Темп занятий высокий, и мы охватываем широкий круг тем!

Расписание курса:

  • День 1, утро (2 часа 10 минут, включая перерывы)
СегментДлительность
Добро пожаловать5 минут
Привет, мир!15 минут
Типы и значения40 минут
Управляющие конструкции45 минут
  • День 1, после обеда (2 часа 45 минут, включая перерывы)
СегментДлительность
Кортежи и массивы35 минут
Ссылки55 минут
Определяемые пользователем типы1 час
  • День 2, утро (2 часа 50 минут, включая перерывы)
СегментДлительность
Добро пожаловать3 минуты
Шаблоны и сопоставление50 минут
Методы и трейты45 минут
Обобщения50 минут
  • День 2, после обеда (2 часа 50 минут, включая перерывы)
СегментДлительность
Замыкания30 минут
Стандартная библиотека: Типы1 час
Стандартная библиотека: Трейты1 час
  • День 3, утро (2 часа 20 минут, включая перерывы)
СегментДлительность
Добро пожаловать3 минуты
Управление памятью1 час
Умные указатели55 минут
  • День 3, после обеда (2 часа 30 минут, включая перерывы)
СегментДлительность
Заимствование1 час 15 минут
Время жизни ссылок1 час 5 минут
  • День 4, утро (2 часа 50 минут, включая перерывы)
СегментДлительность
Добро пожаловать3 минуты
Итераторы55 минут
Модули45 минут
Тестирование45 минут
  • День 4, после обеда (2 часа 20 минут, включая перерывы)
СегментДлительность
Обработка ошибок55 минут
Небезопасный код в Rust1 час 15 минут

Глубокое погружение

В дополнение к четырёхдневному курсу по основам Rust мы рассмотрим ещё несколько специализированных тем:

Rust в Android

Углублённый курс Rust в Android занимает полдня и посвящён использованию Rust для разработки на платформе Android. Включает взаимодействие с C, C++ и Java.

Вам потребуется AOSP. Загрузите репозиторий курса на тот же компьютер, что и AOSP, и переместите каталог src/android/ в корневой каталог вашего AOSP. Это гарантирует, что система сборки Android увидит файлы Android.bp в src/android/.

Убедитесь, что adb sync работает с вашим эмулятором или реальным устройством, и предварительно соберите все примеры Android, используя src/android/build_all.sh. Прочтите скрипт, чтобы увидеть команды, которые он запускает, и убедитесь, что они работают, когда вы запускаете их вручную.

Rust в Chromium

Углублённый курс Rust в Chromium занимает полдня и посвящён использованию Rust как части браузера Chromium. Он включает использование Rust в системе сборки gn Chromium, подключение сторонних библиотек («крейтов») и взаимодействие с C++.

Нужно уметь собрать Chromium: для скорости рекомендуется отладочная сборка в компонентном режиме (component build), но подойдёт любая сборка. Убедитесь, что вы можете запустить собранный браузер Chromium.

Bare-Metal Rust

Углублённый курс Bare-Metal Rust занимает целый день и посвящён использованию Rust для разработки bare-metal (встраиваемых систем). Рассматриваются как микроконтроллеры, так и прикладные процессоры.

Что касается части микроконтроллеров, то вам нужно будет заранее приобрести плату разработки BBC micro:bit v2. Всем нужно будет установить несколько пакетов, как описано на странице приветствия.

Конкурентная многозадачность в Rust

Углублённый курс Конкурентность в Rust занимает целый день и охватывает как классическую конкурентность, так и async/await.

Вам понадобится настроенный новый крейт, а также загруженные зависимости, готовые к работе. Затем вы можете скопировать примеры в src/main.rs, чтобы поэкспериментировать с ними:

cargo init concurrency
cd concurrency
cargo add tokio --features full
cargo run

Расписание курса:

  • Утро (3 часа 20 минут, включая перерывы)
СегментДлительность
Потоки30 минут
Каналы20 минут
Send и Sync15 минут
Разделяемое состояние30 минут
Упражнения1 час 10 минут
  • После обеда (3 часа 30 минут, включая перерывы)
СегментДлительность
Основы асинхронности40 минут
Каналы и поток управления20 минут
Подводные камни55 минут
Упражнения1 час 10 минут

Идиоматичный Rust

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

Перед началом этого курса вы должны освоить материал Основ Rust.

Расписание курса:

  • Утро (14 часов 10 минут, включая перерывы)
СегментДлительность
Основы проектирования API3 часа 15 минут
Использование системы типов7 часов 30 минут
Полиморфизм3 часа 5 минут

Unsafe (в работе)

Углублённый курс Unsafe — двухдневное занятие о небезопасном языке Rust. Он охватывает основы гарантий безопасности Rust, мотивацию для unsafe, процесс ревью небезопасного кода, основы FFI и построение структур данных, которые анализатор заимствований обычно отклоняет.

not found - {{%course outline Unsafe}}

Формат

Курс задуман очень интерактивным, и мы рекомендуем позволять вопросам направлять изучение Rust!

Горячие клавиши

В mdBook есть несколько полезных сочетаний клавиш:

  • Arrow-Left: переход на предыдущую страницу.
  • Arrow-Right: переход на следующую страницу.
  • Ctrl + Enter: выполнение примера кода, находящегося в фокусе.
  • s: активация панели поиска.
  • Упомяните, что эти сочетания клавиш стандартны для mdbook и могут пригодиться при навигации по любому сайту, созданному с помощью mdbook.
  • Вы можете продемонстрировать каждую клавишу слушателям вживую.
  • Клавиша s для поиска особенно полезна, чтобы быстро найти темы, которые обсуждались ранее.
  • Ctrl + Enter будет очень важна для вас, так как вы будете много работать с живым кодом.

Переводы

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

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

Незавершённые переводы

Существует большое количество переводов в работе. Ниже приведены ссылки на наиболее недавно обновлённые:

Полный список переводов с их текущим статусом доступен на момент последнего обновления или с синхронизацией с последней версией курса.

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

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

Использование Cargo

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

Установка

Пожалуйста, следуйте инструкциям на https://rustup.rs/.

Так вы получите систему сборки Cargo (cargo) и компилятор Rust (rustc). Также будет установлен rustup — утилита командной строки, с помощью которой можно устанавливать разные версии компилятора.

После установки Rust настройте редактор или IDE для работы с Rust. Большинство редакторов делают это через rust-analyzer, который обеспечивает автодополнение и переход к определению для VS Code, Emacs, Vim/Neovim и многих других. Существует также отдельная IDE — RustRover.

  • В Debian/Ubuntu rustup можно установить через apt:

    sudo apt install rustup
    
  • В macOS для установки Rust можно использовать Homebrew, но он может предоставлять устаревшую версию. Поэтому рекомендуется установить Rust с официального сайта.

Экосистема Rust

Экосистема Rust состоит из ряда инструментов, основными из которых являются:

  • rustc: компилятор Rust, который превращает файлы .rs в исполняемые файлы и другие промежуточные форматы.

  • cargo: менеджер зависимостей и система сборки Rust. Cargo умеет загружать зависимости, обычно размещённые на https://crates.io, и передавать их rustc при сборке проекта. Cargo также включает встроенное средство запуска тестов, которое выполняет модульные тесты.

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

Ключевые моменты:

  • У Rust стремительный график релизов: новая версия выходит каждые шесть недель. Новые версии обратно совместимы со старыми и при этом добавляют новые возможности.

  • Существует три вида релизов: «stable», «beta» и «nightly».

  • Новые возможности тестируются в «nightly», а «beta» — это то, что становится «stable» каждые шесть недель.

  • Зависимости также могут подтягиваться из альтернативных реестров, git-репозиториев, локальных папок и других источников.

  • У Rust также есть редакции: текущая редакция — Rust 2024. Предыдущими были редакции Rust 2015, Rust 2018 и Rust 2021.

    • Редакциями позволено вносить обратно несовместимые изменения в язык.

    • Чтобы избежать сбоев, редакцию для своего пакета можно явно указать в файле Cargo.toml.

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

    • Стоит упомянуть, что использование компилятора напрямую, а не через cargo, является редким явлением (большинство пользователей никогда этого не делают).

    • Стоит отметить, что сам Cargo — чрезвычайно мощный и всеобъемлющий инструмент. Он умеет многое, в том числе:

    • Подробнее читайте в официальной книге по Cargo

Примеры кода в этом курсе

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

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

Блоки кода в этом курсе полностью интерактивны:

// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    println!("Edit me!");
}

Можно использовать Ctrl + Enter, чтобы выполнить код, когда фокус находится в текстовом поле.

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

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

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

Запуск кода локально с помощью Cargo

Если вы хотите поэкспериментировать с кодом на своей системе, то вам нужно будет сначала установить Rust. Сделайте это, следуя инструкциям в The Rust Book. В вашей системе появятся инструменты rustc и cargo. На момент написания курса последний стабильный выпуск Rust имеет следующие версии:

% rustc --version
rustc 1.69.0 (84c898d65 2023-04-16)
% cargo --version
cargo 1.69.0 (6e9a83356 2023-04-12)

Можно использовать и более поздние версии: Rust сохраняет обратную совместимость.

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

  1. Нажмите кнопку «Copy to clipboard» на примере кода, который вы хотите скопировать.

  2. Используйте cargo new exercise, чтобы создать новый каталог exercise/ для вашего кода:

    $ cargo new exercise
         Created binary (application) `exercise` package
    
  3. Перейдите в каталог exercise/ и выполните cargo run для сборки и запуска исполняемого файла:

    $ cd exercise
    $ cargo run
       Compiling exercise v0.1.0 (/home/mgeisler/tmp/exercise)
        Finished dev [unoptimized + debuginfo] target(s) in 0.75s
         Running `target/debug/exercise`
    Hello, world!
    
  4. Замените шаблонный код в src/main.rs своим. Например, используя пример с предыдущей страницы, сделайте src/main.rs похожим на

    // Copyright 2022 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    fn main() {
        println!("Edit me!");
    }
  5. Используйте cargo run для сборки и запуска обновленного исполняемого файла:

    $ cargo run
       Compiling exercise v0.1.0 (/home/mgeisler/tmp/exercise)
        Finished dev [unoptimized + debuginfo] target(s) in 0.24s
         Running `target/debug/exercise`
    Edit me!
    
  6. Используйте cargo check для быстрой проверки проекта на наличие ошибок и cargo build для компиляции проекта без его запуска. Вы найдёте результат в каталоге target/debug/ для отладочной сборки. Используйте cargo build --release для создания оптимизированной финальной сборки в target/release/.

  7. Вы можете добавить зависимости для вашего проекта, отредактировав файл Cargo.toml. Когда вы запустите команду cargo, она автоматически загрузит и скомпилирует недостающие зависимости для вас.

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

Добро пожаловать в День 1

Это первый день курса «Основы Rust». Сегодня мы рассмотрим широкий круг тем:

  • Базовый синтаксис Rust: переменные, скалярные и составные типы, перечисления, структуры, ссылки, функции и методы.
  • Типы и вывод типов.
  • Управляющие конструкции: циклы, условные операторы и так далее.
  • Пользовательские типы: структуры и перечисления.

Расписание

Вместе с 10-минутными перерывами занятие займёт около 2 часов 10 минут. Оно включает:

СегментДлительность
Добро пожаловать5 минут
Привет, мир!15 минут
Типы и значения40 минут
Управляющие конструкции45 минут
This slide should take about 5 minutes.

Пожалуйста, напомните студентам, что:

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

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

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

Привет, мир!

Этот сегмент займёт около 15 минут. Он включает:

СлайдДлительность
Что такое Rust?10 минут
Сильные стороны3 минуты
Песочница2 минуты

Что такое Rust?

Rust — новый язык программирования, версия 1.0 которого вышла в 2015 году:

  • Rust — это статически компилируемый язык, выступающий в той же роли, что и C++.
    • rustc использует LLVM в качестве бэкенда.
  • Rust поддерживает множество платформ и архитектур:
    • x86, ARM, WebAssembly, …
    • Linux, Mac, Windows, …
  • Rust используется для широкого спектра устройств:
    • прошивки и загрузчики,
    • смарт-экраны,
    • мобильные устройства,
    • настольные компьютеры,
    • серверы.
This slide should take about 10 minutes.

Rust подходит для той же области, что и C++:

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

Сильные стороны

Некоторые уникальные преимущества Rust:

  • Безопасность памяти на этапе компиляции — целые классы ошибок работы с памятью предотвращаются во время компиляции

    • Нет неинициализированных переменных.
    • Нет двойных освобождений.
    • Нет использования-после-освобождения (use-after-free).
    • Нет NULL указателей.
    • Нет забытых заблокированных мьютексов.
    • Нет гонок данных между потоками.
    • Нет инвалидации итераторов.
  • Отсутствие неопределённого поведения во время выполнения — то, что делает каждая инструкция Rust, никогда не остаётся неопределённым

    • Проверка границ массива.
    • Переполнение целых чисел определено (паника или циклическое переполнение).
  • Современные возможности языка — такие же выразительные и удобные, как в языках высокого уровня

    • Перечисления и сопоставление с образцом.
    • Обобщения (generics).
    • Нет накладных расходов для FFI.
    • Абстракции с нулевой стоимостью (zero-cost abstractions).
    • Отличные ошибки компилятора.
    • Встроенный менеджер зависимостей.
    • Встроенная поддержка тестирования.
    • Великолепная поддержка протокола языкового сервера (LSP).
This slide should take about 3 minutes.

Не тратьте на это много времени. Все эти пункты будут разобраны подробнее позже.

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

  • Опыт работы с C или C++: Rust устраняет целый класс ошибок времени выполнения с помощью анализатора заимствований. Вы получаете производительность, как в C и C++, не сталкиваясь с проблемами безопасности памяти. Кроме того, вы получаете современный язык с такими конструкциями, как сопоставление с образцом и встроенное управление зависимостями.

  • Опыт работы с Java, Go, Python, JavaScript…: вы получаете ту же безопасность памяти, что и в этих языках, плюс похожее ощущение языка высокого уровня. Кроме того, вы получаете быструю и предсказуемую производительность, как у C и C++ (без сборщика мусора), а также доступ к низкоуровневому оборудованию (если понадобится).

Песочница

Rust Playground — удобный способ запускать короткие программы на Rust; на нём основаны примеры и упражнения курса. Попробуйте запустить программу «hello-world», с которой он начинается. В нём есть несколько полезных функций:

  • В меню «Tools» используйте опцию rustfmt, чтобы отформатировать код по «стандартным» правилам.

  • В Rust есть два основных «профиля» сборки кода: Debug (дополнительные проверки во время выполнения, меньше оптимизаций) и Release (меньше проверок во время выполнения, много оптимизаций). Переключаться между ними можно через меню «Debug» вверху страницы.

  • Если интересно, используйте «ASM» в меню «…», чтобы посмотреть сгенерированный ассемблерный код.

This slide should take about 2 minutes.

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

Типы и значения

Этот сегмент займёт около 40 минут. Он включает:

СлайдДлительность
Привет, мир!5 минут
Переменные5 минут
Литералы5 минут
Арифметические операции3 минуты
Выведение типов3 минуты
Упражнение: Числа Фибоначчи15 минут

Привет, мир!

Давайте перейдём к самой простой из возможных программ на Rust, классической программе «Привет, мир!»

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    println!("Привет, 🌍!");
}

Что вы видите:

  • Функции объявляются с помощью fn.
  • Функция main является точкой входа в программу.
  • Блоки разделяются фигурными скобками, как в C и C++.
  • Инструкции завершаются ;.
  • println — это макрос, на что указывает ! при вызове.
  • Строки закодированы в UTF-8 и могут содержать любой символ Юникода.
This slide should take about 5 minutes.

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

Ключевые моменты:

  • Rust очень похож на другие языки традиции C/C++/Java. Он императивный и не пытается изобретать что-то заново без крайней необходимости.

  • Rust современный и полностью поддерживает Юникод.

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

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

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

Переменные

Rust обеспечивает типобезопасность за счёт статической типизации. Привязки переменных создаются с помощью let:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let x: i32 = 10;
    println!("x: {x}");
    // x = 20;
    // println!("x: {x}");
}
This slide should take about 5 minutes.
  • Раскомментируйте x = 20, чтобы показать, что по умолчанию переменные неизменяемы. Добавьте ключевое слово mut, чтобы разрешить изменения.

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

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

Литералы

Вот несколько основных встроенных типов и синтаксис литералов для каждого из них.

ТипыЛитералы
Знаковые целые числаi8, i16, i32, i64, i128, isize-10, 0, 1_000, 123_i64
Беззнаковые целые числаu8, u16, u32, u64, u128, usize0, 123, 10_u16
Числа с плавающей запятойf32, f643.14, -10.0e20, 2_f32
Символы Юникодаchar'a', 'α', '∞'
Логические значенияbooltrue, false

Типы имеют следующие размеры:

  • iN, uN и fN – N бит,
  • isize и usize – размерность указателя,
  • char – 32 бита,
  • bool – 8 бит.
This slide should take about 5 minutes.

Есть несколько вариантов синтаксиса, которые не показаны выше:

  • Все подчёркивания в числах можно опускать — они нужны только для удобочитаемости. Так 1_000 можно записать как 1000 (или 10_00), а 123_i64 — как 123i64.

Арифметические операции

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn interproduct(a: i32, b: i32, c: i32) -> i32 {
    return a * b + b * c + c * a;
}

fn main() {
    println!("result: {}", interproduct(120, 100, 248));
}
This slide should take about 3 minutes.

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

Арифметика очень похожа на другие языки, с похожим приоритетом операций.

А что насчёт переполнения целых чисел? В C и C++ переполнение знаковых целых на самом деле не определено и может привести к непредсказуемому поведению во время выполнения. В Rust оно определено.

Замените i32 на i16, чтобы увидеть целочисленное переполнение: в отладочной сборке оно вызывает панику (checked), а в релизной — выполняет обёртывание (wrapping). Есть и другие варианты: overflowing, saturating, carrying. Они доступны через синтаксис методов, например (a * b).saturating_add(b * c).saturating_add(c * a).

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

Выведение типов

Rust анализует использование переменной для определения её типа:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn takes_u32(x: u32) {
    println!("u32: {x}");
}

fn takes_i8(y: i8) {
    println!("i8: {y}");
}

fn main() {
    let x = 10;
    let y = 20;

    takes_u32(x);
    takes_i8(y);
    // takes_u32(y);
}
This slide should take about 3 minutes.

Слайд показывает, что компилятор языка выводит неявно заданный тип значения переменной, основываясь на её объявлении и дальнейшем использовании.

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

По умолчанию, в отсутствие ограничений, тип целочисленного литерала будет выведен как i32. В сообщениях об ошибках он иногда отображается как {integer}. Аналогично, умолчание для литералов чисел с плавающей точкой — f64

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let x = 3.14;
    let y = 20;
    assert_eq!(x, y);
    // ОШИБКА: no implementation for `{float} == {integer}`
}

Упражнение: Числа Фибоначчи

Последовательность Фибоначчи начинается с [0, 1]. Для n > 1 следующее число равно сумме двух предыдущих.

Напишите функцию fib(n), которая вычисляет n-е число Фибоначчи. При каких условиях эта функция вызовет панику?

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn fib(n: u32) -> u32 {
    if n < 2 {
        // Базовый случай.
        return todo!("Реализуйте это");
    } else {
        // Рекурсивный случай.
        return todo!("Реализуйте это");
    }
}

fn main() {
    let n = 20;
    println!("fib({n}) = {}", fib(n));
}
This slide and its sub-slides should take about 15 minutes.
  • Это упражнение — классическое введение в рекурсию.
  • Подтолкните студентов подумать о базовых случаях и о рекурсивном шаге.
  • Вопрос «При каких условиях эта функция вызовет панику?» — подсказка подумать о переполнении целых. Последовательность Фибоначчи растёт очень быстро!
  • Студенты могут предложить и итеративное решение, и это отличный повод обсудить компромиссы между рекурсией и итерацией (например, производительность и переполнение стека при глубокой рекурсии).

Решение

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn fib(n: u32) -> u32 {
    if n < 2 {
        return n;
    } else {
        return fib(n - 1) + fib(n - 2);
    }
}

fn main() {
    let n = 20;
    println!("fib({n}) = {}", fib(n));
}

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

Условие if n < 2 не требует скобок, это стандартный стиль Rust.

Паника

В упражнении спрашивается, при каких условиях эта функция вызовет панику. Последовательность Фибоначчи растёт очень быстро. Для u32 вычисляемые значения превысят 32-битный предел (4 294 967 295), когда n достигнет 48.

В Rust арифметика целых проверяет переполнение в отладочном режиме (он используется по умолчанию при cargo run). Если переполнение происходит, программа паникует (аварийно завершается с сообщением об ошибке). В релизном режиме (cargo run --release) проверки переполнения по умолчанию отключены, и число «переходит» через границу (арифметика по модулю), что даёт неверный результат.

  • Пройдите решение по шагам.
  • Объясните рекурсивные вызовы и то, как они приводят к итоговому результату.
  • Обсудите проблему переполнения целых. Для u32 функция вызовет панику примерно при n = 47. Можно показать это, изменив входное значение в main.
  • Покажите итеративное решение как альтернативу и сравните его производительность и использование памяти с рекурсивным. Итеративное решение будет намного эффективнее.

Для дальнейшего изучения

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

Управляющие конструкции

Этот сегмент займёт около 45 минут. Он включает:

СлайдДлительность
Блоки и области видимости5 минут
Выражение if4 минуты
Выражения match5 минут
Циклы5 минут
break и continue4 минуты
Функции3 минуты
Макросы2 минуты
Упражнение: Последовательность Коллатца15 минут
  • Теперь рассмотрим разные виды управления потоком выполнения в Rust.

  • Большая часть будет вам знакома по другим языкам программирования.

Блоки и области видимости

  • Блок в Rust содержит последовательность выражений, заключённую в фигурные скобки {}.
  • Последнее выражение блока определяет значение и тип всего блока.
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let z = 13;
    let x = {
        let y = 10;
        dbg!(y);
        z - y
    };
    dbg!(x);
    // dbg!(y);
}

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

Область видимости переменной ограничена объемлющим блоком.

This slide should take about 5 minutes.
  • Можно объяснить, что dbg! — это макрос Rust, который выводит и возвращает значение заданного выражения для быстрой отладки.

  • Можно показать, как меняется значение блока при изменении последней строки в блоке. Например, можно добавить/убрать точку с запятой или использовать return.

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

  • Значения фактически «освобождаются» при выходе из области видимости, даже если их данные на стеке всё ещё лежат там.

Выражение if

Выражение if работает так же, как и оператор if в других языках:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let x = 10;
    if x == 0 {
        println!("zero!");
    } else if x < 100 {
        println!("biggish");
    } else {
        println!("huge");
    }
}

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

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let x = 10;
    let size = if x < 20 { "small" } else { "large" };
    println!("number size: {}", size);
}
This slide should take about 4 minutes.

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

Выражение if используется так же, как и другие выражения. Например, если оно стоит в операторе let, то и оператор должен заканчиваться ;. Уберите ; перед println!, чтобы увидеть ошибку компилятора.

Выражения match

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

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let val = 1;
    match val {
        1 => println!("one"),
        10 => println!("ten"),
        100 => println!("one hundred"),
        _ => {
            println!("something else");
        }
    }
}

Как и выражения if, match тоже может возвращать значение;

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let flag = true;
    let val = match flag {
        true => 1,
        false => 0,
    };
    println!("The value of {flag} is {val}");
}
This slide should take about 5 minutes.
  • Ветви match проверяются сверху вниз, и выполняется тело первой подходящей ветви.

  • В отличие от switch в других языках, здесь нет «проваливания» (fall-through) между ветвями.

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

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

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

Для дальнейшего изучения

  • Чтобы лучше объяснить пользу match, можно сравнить примеры с эквивалентами на if. Во втором случае, при сопоставлении с bool, блок if {} else {} очень похож. Но в первом примере, где проверяется несколько вариантов, выражение match может быть короче, чем if {} else if {} else if {} else.

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

Циклы

В Rust есть три ключевых слова для циклов: while, loop и for:

Выражение while

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let mut x = 200;
    while x >= 10 {
        x = x / 2;
    }
    dbg!(x);
}

for

Цикл for перебирает диапазоны значений или элементы коллекции:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    for x in 1..5 {
        dbg!(x);
    }

    for elem in [2, 4, 8, 16, 32] {
        dbg!(elem);
    }
}
  • Циклу for можно передать любую последовательность значений, и он пройдёт по ней.

  • Обратите внимание, что первый цикл for доходит только до 4. Покажите синтаксис 1..=5 для включающего диапазона.

Для дальнейшего изучения

  • Под капотом циклы for используют концепцию «итераторов», чтобы перебирать разные виды диапазонов и коллекций. Итераторы будут рассмотрены подробнее позже.

loop

Оператор loop повторяется бесконечно, пока не встретится break.

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let mut i = 0;
    loop {
        i += 1;
        dbg!(i);
        if i > 100 {
            break;
        }
    }
}
  • Оператор loop работает как цикл while true. Используйте его для таких задач, как серверы, которые обслуживают соединения бесконечно.

break и continue

Если нужно сразу перейти к следующей итерации, используйте continue.

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let mut i = 0;
    loop {
        i += 1;
        if i > 5 {
            break;
        }
        if i % 2 == 0 {
            continue;
        }
        dbg!(i);
    }
}
This slide and its sub-slides should take about 4 minutes.

Обратите внимание, что loop — единственная конструкция цикла, которая может вернуть нетривиальное значение. Это связано с тем, что она гарантированно завершается только через break (в отличие от циклов while и for, которые также могут завершиться, когда условие становится ложным).

Метки

И continue, и break могут принимать необязательную метку, которая используется для выхода из вложенных циклов:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let s = [[5, 6, 7], [8, 9, 10], [21, 15, 32]];
    let mut elements_searched = 0;
    let target_value = 10;
    'outer: for i in 0..=2 {
        for j in 0..=2 {
            elements_searched += 1;
            if s[i][j] == target_value {
                break 'outer;
            }
        }
    }
    dbg!(elements_searched);
}
  • Помеченный break также работает с произвольными блоками, например:
    #![allow(unused)]
    fn main() {
    // Copyright 2024 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    'label: {
        break 'label;
        println!("This line gets skipped");
    }
    }

Функции

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn gcd(a: u32, b: u32) -> u32 {
    if b > 0 { gcd(b, a % b) } else { a }
}

fn main() {
    dbg!(gcd(143, 52));
}
This slide should take about 3 minutes.
  • В объявлении функции за каждым параметром следует его тип (в отличие от некоторых языков программирования), затем тип возвращаемого значения.
  • Последнее выражение в теле функции (или любого блока) становится возвращаемым значением. Просто опустите ; в конце выражения. Ключевое слово return можно использовать для раннего возврата, но форма «голого значения» идиоматична в конце функции (перепишите gcd с использованием return).
  • Некоторые функции не возвращают значения и возвращают «тип unit» (). Компилятор выведет это, если возвращаемый тип не указан.
  • Перегрузка не поддерживается: каждая функция имеет единственную реализацию.
    • Функция всегда принимает фиксированное количество параметров. Аргументы по умолчанию не поддерживаются. Для функций с переменным числом аргументов можно использовать макросы.
    • Всегда принимает один набор типов параметров. Эти типы могут быть обобщёнными; об этом поговорим позже.

Макросы

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

  • println!(format, ..) выводит строку в стандартный вывод, применяя форматирование, описанное в std::fmt.
  • format!(format, ..) работает так же, как println!, но возвращает результат в виде строки.
  • dbg!(expression) выводит значение выражения в лог и возвращает его.
  • todo!() отмечает фрагмент кода как ещё не реализованный. При выполнении он вызовет панику.
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn factorial(n: u32) -> u32 {
    let mut product = 1;
    for i in 1..=n {
        product *= dbg!(i);
    }
    product
}

fn fizzbuzz(n: u32) -> u32 {
    todo!()
}

fn main() {
    let n = 4;
    println!("{n}! = {}", factorial(n));
}
This slide should take about 2 minutes.

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

Курс не рассматривает определение макросов, но в одном из следующих разделов описано использование derive-макросов.

Для дальнейшего изучения

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

  • assert! и связанные с ним макросы позволяют добавлять проверки в код. Они широко используются при написании тестов.
  • unreachable! отмечает ветку потока управления, которая никогда не должна выполняться.
  • eprintln! позволяет выводить текст в stderr.

Упражнение: Последовательность Коллатца

Последовательность Коллатца определяется следующим образом для произвольного n1, большего нуля:

  • Если ni равно 1, то последовательность завершается на ni.
  • Если ni чётное, то ni+1 = ni / 2.
  • Если ni нечётное, то ni+1 = 3 * ni + 1.

Например, начиная с n1 = 3:

  • 3 нечётное, поэтому n2 = 3 * 3 + 1 = 10;
  • 10 чётное, поэтому n3 = 10 / 2 = 5;
  • 5 нечётное, поэтому n4 = 3 * 5 + 1 = 16;
  • 16 чётное, поэтому n5 = 16 / 2 = 8;
  • 8 чётное, поэтому n6 = 8 / 2 = 4;
  • 4 чётное, поэтому n7 = 4 / 2 = 2;
  • 2 чётное, поэтому n8 = 1; и
  • последовательность завершается.

Напишите функцию, которая вычисляет длину последовательности Коллатца для заданного начального n.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Определяет длину последовательности Коллатца, начинающейся с `n`.
fn collatz_length(mut n: i32) -> u32 {
  todo!("Реализуйте это")
}

fn main() {
    println!("Length: {}", collatz_length(11)); // должно быть 15
}

Решение

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Определяет длину последовательности Коллатца, начинающейся с `n`.
fn collatz_length(mut n: i32) -> u32 {
    let mut len = 1;
    while n > 1 {
        n = if n % 2 == 0 { n / 2 } else { 3 * n + 1 };
        len += 1;
    }
    len
}

fn main() {
    println!("Length: {}", collatz_length(11)); // должно быть 15
}

Это решение демонстрирует несколько ключевых возможностей Rust:

  • Аргументы mut: аргумент n объявлен как mut n. Это делает локальную переменную n изменяемой в области видимости функции. Это не влияет на значение у вызывающего кода, так как целые — типы Copy, которые передаются по значению.
  • Выражения if: if в Rust — это выражение, то есть оно порождает значение. Мы присваиваем результат блока if/else напрямую n. Это короче, чем писать n = ... внутри каждой ветви.
  • Неявный возврат: функция заканчивается на len (без точки с запятой), и это значение автоматически возвращается.
  • Обратите внимание, что для корректности последовательности Коллатца n должно быть строго больше 0. Функция принимает i32, но в постановке задачи подразумеваются положительные целые. Более надёжная реализация могла бы использовать u32 или возвращать Option или Result для обработки некорректных входных данных (0 или отрицательных чисел). Но при n <= 0 возможны паника или бесконечный цикл.
  • Переполнение — возможная проблема, если n становится слишком большим, как и в упражнении с Фибоначчи.

Добро пожаловать

Включая 10-минутные перерывы, это занятие займёт около 2 часов 45 минут. Оно включает:

СегментДлительность
Кортежи и массивы35 минут
Ссылки55 минут
Определяемые пользователем типы1 час

Кортежи и массивы

Этот сегмент займёт около 35 минут. Он включает:

СлайдДлительность
Массивы5 минут
Кортежи5 минут
Обход массивов3 минуты
Деструктуризация5 минут
Упражнение: Вложенные массивы15 минут
  • Мы уже видели, как работают примитивные типы в Rust. Теперь пришло время вам начать создавать собственные составные типы.

Массивы

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let mut a: [i8; 5] = [5, 4, 3, 2, 1];
    a[2] = 0;
    println!("a: {a:?}");
}
This slide should take about 5 minutes.
  • Массивы также можно инициализировать краткой записью, например [0; 1024]. Это удобно, когда нужно задать все элементы одинаковым значением или когда большой массив сложно инициализировать вручную.

  • Значение массива [T; N] содержит N элементов одинакового типа T. Где N (длина массива) — это константа времени компиляции. Обратите внимание: длина является частью типа, поэтому [u8; 3] и [u8; 4] — это разные типы. Срезы, длина которых определяется во время выполнения, рассматриваются в курсе позднее.

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

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let mut a: [i8; 5] = [5, 4, 3, 2, 1];
    a[6] = 0;
    println!("a: {a:?}");
}
  • Обращения к массиву проверяются во время выполнения. Rust по возможности убирает эти проверки: если компилятор может доказать, что обращение безопасно, он удаляет проверку ради производительности. Их можно обойти, используя небезопасный Rust. Оптимизация настолько хороша, что трудно привести пример того, как проверки во время выполнения срабатывают. Следующий код скомпилируется, но вызовет панику во время выполнения:
// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn get_index() -> usize {
    6
}

fn main() {
    let mut a: [i8; 5] = [5, 4, 3, 2, 1];
    a[get_index()] = 0;
    println!("a: {a:?}");
}
  • Мы можем использовать литералы для присваивания значений массивам.

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

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

Отладочный вывод

  • Параметр форматирования ? предписывает макросу println! запросить реализацию трейта отладочного вывода: {} запрашивает форматирование по умолчанию (std::fmt::Display), {:?} – отладочное представление (std::fmt::Debug). Например, целочисленные типы и строки реализуют трейт Display, но для массивов реализован только трейт Debug. Поэтому использование {:?} здесь обязательно.

  • Добавление #, например {a:#?}, позволяет использовать более наглядный формат «красивой печати» с отступами и переносами строк.

Кортежи

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let t: (i8, bool) = (7, true);
    dbg!(t.0);
    dbg!(t.1);
}
This slide should take about 5 minutes.
  • Как и массивы, кортежи имеют фиксированную длину.

  • Кортежи группируют значения разных типов в составной тип.

  • Доступ к полям кортежа можно получить с помощью точки и индекса значения, например t.0, t.1.

  • Пустой кортеж () называется единичным типом и обозначает пустое значение или пустой возвращаемый тип. Похоже на void в других языках.

  • В отличие от массивов, кортежи нельзя использовать в цикле for. Это потому, что for требует, чтобы все элементы имели один и тот же тип, а в кортеже это может быть не так.

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

Обход массивов

Выражение for позволяет обходить элементы массива (но не кортежа).

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let primes = [2, 3, 5, 7, 11, 13, 17, 19];
    for prime in primes {
        for i in 2..prime {
            assert_ne!(prime % i, 0);
        }
    }
}
This slide should take about 3 minutes.

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

Здесь новинка — макрос assert_ne!. Есть также макросы assert_eq! и assert!. Они проверяются всегда, тогда как отладочные варианты, например debug_assert!, в релизных сборках не компилируются.

Деструктуризация

Rust поддерживает сопоставление с образцом, чтобы разбить большое значение, например кортеж, на составные части:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn check_order(tuple: (i32, i32, i32)) -> bool {
    let (left, middle, right) = tuple;
    left < middle && middle < right
}

fn main() {
    let tuple = (1, 5, 3);
    println!(
        "{tuple:?}: {}",
        if check_order(tuple) { "ordered" } else { "unordered" }
    );
}
This slide should take about 5 minutes.
  • Используемые здесь шаблоны являются «неопровержимыми», то есть компилятор может статически проверить, что значение справа от = имеет ту же структуру, что и шаблон.
  • Имя переменной – это неопровержимый шаблон, который всегда соответствует любому значению, отсюда возможность использовать let для объявления единственной переменной.
  • Rust также поддерживает использование шаблонов в условных выражениях, позволяя одновременно выполнять сравнение на равенство и деструктуризацию. Этот вариант использования шаблонов подробно рассматривается позднее.
  • Отредактируйте пример, чтобы показать ошибку компиляции, когда шаблон не соответствует сопоставляемому значению.

Упражнение: Вложенные массивы

Элементами массива также могут быть массивы:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

let array = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];

Каков тип этой переменной?

Используя пример выше, напишите функцию transpose, которая транспонирует матрицу (превращает строки в столбцы):

2584567⎤8⎥9⎦transpose==⎛⎡1⎜⎢4⎝⎣73⎤⎞6⎥⎟9⎦⎠⎡1⎢2⎣3

Скопируйте код ниже в https://play.rust-lang.org/ и реализуйте функцию. Эта функция работает только с матрицами 3×3.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn transpose(matrix: [[i32; 3]; 3]) -> [[i32; 3]; 3] {
    todo!()
}

fn main() {
    let matrix = [
        [101, 102, 103], // <-- rustfmt сохранит перенос на следующую строку
        [201, 202, 203],
        [301, 302, 303],
    ];

    println!("Original:");
    for row in matrix {
        println!("{row:?}");
    }

    let transposed = transpose(matrix);

    println!("\nTransposed:");
    for row in transposed {
        println!("{row:?}");
    }
}

Решение

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn transpose(matrix: [[i32; 3]; 3]) -> [[i32; 3]; 3] {
    let mut result = [[0; 3]; 3];
    for i in 0..3 {
        for j in 0..3 {
            result[j][i] = matrix[i][j];
        }
    }
    result
}

fn main() {
    let matrix = [
        [101, 102, 103], // <-- rustfmt сохранит перенос на следующую строку
        [201, 202, 203],
        [301, 302, 303],
    ];

    println!("Original:");
    for row in matrix {
        println!("{row:?}");
    }

    let transposed = transpose(matrix);

    println!("\nTransposed:");
    for row in transposed {
        println!("{row:?}");
    }
}
  • Типы массивов: тип [[i32; 3]; 3] обозначает массив размера 3, каждый элемент которого сам является массивом из 3 значений i32. Так обычно представляются многомерные массивы в Rust.
  • Инициализация: мы инициализируем result нулями ([[0; 3]; 3]) перед заполнением. Rust требует, чтобы все переменные были инициализированы до использования; в безопасном Rust понятия «неинициализированной памяти» нет.
  • Семантика копирования: массивы элементов типа Copy (например, i32) сами являются Copy. Когда мы передаём matrix в функцию, она копируется по значению. Переменная result — новый отдельный массив.
  • Итерация: мы используем обычные циклы for с диапазонами (0..3), чтобы перебрать индексы. В Rust есть и мощные итераторы, которые мы рассмотрим позже, но для транспонирования матрицы индексация достаточно проста.
  • Отметьте, что [i32; 3] — это другой тип, чем [i32; 4]. Размеры массивов входят в сигнатуру типа.
  • Спросите студентов, что произойдёт, если они попытаются вернуть matrix напрямую после изменения (если поменяли сигнатуру на mut matrix). (Ответ: это сработает, но вернётся изменённая копия, а исходный массив в main останется без изменений.)

Ссылки

Этот сегмент займёт около 55 минут. Он включает:

СлайдДлительность
Общие ссылки10 минут
Эксклюзивные ссылки5 минут
Срезы10 минут
Строки10 минут
Допустимость ссылок3 минуты
Упражнение: Геометрия20 минут

Общие ссылки

Ссылка даёт доступ к другому значению, не забирая владение этим значением; её также называют «заимствованием». Неизменяемые ссылки доступны только для чтения, и данные, на которые они указывают, не могут меняться.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let a = 'A';
    let b = 'B';

    let mut r: &char = &a;
    dbg!(r);

    r = &b;
    dbg!(r);
}

Неизменяемая ссылка на тип T имеет тип &T. Значение-ссылка создаётся оператором &. Оператор * «разыменовывает» ссылку, возвращая её значение.

This slide should take about 7 minutes.
  • Ссылки в Rust никогда не бывают null, поэтому проверка на null не нужна.

  • Говорят, что ссылка «заимствует» значение, на которое указывает. Это хорошая модель для студентов, не знакомых с указателями: код может использовать ссылку для доступа к значению, но оно по-прежнему «принадлежит» исходной переменной. Владение курс рассмотрит подробнее в третий день.

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

  • Явное взятие ссылки с помощью & обязательно, за исключением вызова методов: там Rust выполняет автоматическое взятие и разыменование ссылок.

  • В некоторых случаях Rust автоматически разыменовывает ссылки, в частности при вызове методов (попробуйте r.is_ascii()). Оператор ->, как в C++, не нужен.

  • В этом примере r объявлена изменяемой, чтобы её можно было переназначить (r = &b). Обратите внимание, что это меняет привязку r: она теперь ссылается на другое значение. Это отличается от C++, где присваивание ссылке меняет значение, на которое она указывает.

  • Неизменяемая ссылка не позволяет менять значение, на которое она указывает, даже если это значение было изменяемым. Попробуйте *r = 'X'.

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

  • Подробнее о заимствовании и предотвращении висячих ссылок мы поговорим, когда перейдём к владению.

Эксклюзивные ссылки

Эксклюзивные ссылки, также известные как изменяемые ссылки, позволяют менять значение, на которое они указывают. Их тип — &mut T.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let mut point = (1, 2);
    let x_coord = &mut point.0;
    *x_coord = 20;
    println!("point: {point:?}");
}
This slide should take about 5 minutes.

Ключевые моменты:

  • «Эксклюзивный» означает, что значение можно получить только через эту ссылку. Никакие другие ссылки (ни неизменяемые, ни эксклюзивные) не могут существовать одновременно, и значение нельзя трогать, пока существует эксклюзивная ссылка. Попробуйте создать &point.0 или изменить point.0, пока жива x_coord.

  • Обратите внимание на разницу между let mut x_coord: &i32 и let x_coord: &mut i32. Первая — неизменяемая ссылка, которую можно привязать к разным значениям, а вторая — эксклюзивная ссылка на изменяемое значение.

Срезы

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

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let a: [i32; 6] = [10, 20, 30, 40, 50, 60];
    println!("a: {a:?}");

    let s: &[i32] = &a[2..4];
    println!("s: {s:?}");
}
  • Срезы заимствуют данные у коллекции.
This slide should take about 7 minutes.
  • Мы создаем срез, заимствуя a и указывая начальный и конечный индексы в скобках.

  • Если срез начинается с индекса 0, синтаксис Rust позволяет отбросить начальный индекс, таким образом, &a[0..a.len()] и &a[..a.len()] идентичны.

  • То же верно и для последнего индекса, поэтому &a[2..a.len()] и &a[2..] идентичны.

  • Чтобы создать срез всего массива, можно использовать &a[..].

  • s — это ссылка на срез элементов типа i32. Обратите внимание, что тип s (&[i32]) больше не указывает длину массива. Это позволяет выполнять вычисления на срезах разных размеров.

  • Срезы всегда заимствуют у другого объекта. В этом примере a должен оставаться «живым» (в области видимости) по крайней мере столько же, сколько и наш срез.

  • Срез нельзя «вырастить» после создания:

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

Строки

Теперь давайте разберемся с двумя типами строк в Rust:

  • &str — это срез из байтов в кодировке UTF-8, аналогичный &[u8].
  • String — это владеющий буфер из байтов в кодировке UTF-8, похожий на Vec<T>.
// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let s1: &str = "World";
    println!("s1: {s1}");

    let mut s2: String = String::from("Привет ");
    println!("s2: {s2}");

    s2.push_str(s1);
    println!("s2: {s2}");

    let s3: &str = &s2[2..9];
    println!("s3: {s3}");
}
This slide should take about 10 minutes.
  • &str задаёт строковый срез — неизменяемую ссылку на строковые данные в кодировке UTF-8, которые хранятся в блоке памяти. Строковые литералы ("Hello") хранятся в бинарном файле программы.

  • Тип String в Rust — это обёртка над вектором байтов. Как и Vec<T>, он владеет своими данными.

  • Как и многие другие типы, String::from() создаёт строку из строкового литерала; String::new() создаёт новую пустую строку, в которую можно добавлять данные с помощью методов push() и push_str().

  • Макрос format!() — удобный способ создать владеющую строку из динамических значений. Он принимает ту же спецификацию формата, что и println!().

  • Из String можно заимствовать срезы &str через & и, при желании, выбрав диапазон. Если выбрать диапазон байтов, не выровненный по границам символов, выражение вызовет панику. Итератор chars перебирает символы; его лучше использовать, чем пытаться вручную вычислять границы символов.

  • Программистам на C++: думайте о &str как о std::string_view из C++, но который всегда указывает на корректную строку в памяти. String в Rust примерно соответствует std::string из C++ (основное отличие: он может содержать только байты в кодировке UTF-8 и никогда не использует оптимизацию для коротких строк).

  • Байтовые строковые литералы позволяют напрямую создать значение &[u8]:

    // Copyright 2024 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    fn main() {
        println!("{:?}", b"abc");
        println!("{:?}", &[97, 98, 99]);
    }
  • Сырые строки позволяют создавать значение &str с отключенным экранированием: r"\n" == "\\n". Вы можете поставить двойные кавычки, используя равное количество символов # с обеих сторон кавычек:

    // Copyright 2024 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    fn main() {
        println!(r#"<a href="link.html">link</a>"#);
        println!("<a href=\"link.html\">link</a>");
    }

Допустимость ссылок

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

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let x_ref = {
        let x = 10;
        &x
    };
    dbg!(x_ref);
}
This slide should take about 3 minutes.
  • Этот слайд заставляет студентов думать о ссылках не просто как об указателях, ведь в Rust для ссылок действуют другие правила, чем в других языках.

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

Для дальнейшего изучения

  • Аналогом «nullable» в Rust служит тип Option, с помощью которого можно сделать «nullable» любой тип (не только ссылки и указатели). Однако перечисления и сопоставление с образцом мы ещё не вводили, поэтому постарайтесь не углубляться в эту тему.

Упражнение: Геометрия

Мы напишем несколько вспомогательных функций для трёхмерной геометрии, представляя точку как [f64;3]. Сигнатуры функций вы определите сами.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

// Вычислите длину вектора, сложив квадраты его координат
// и взяв квадратный корень. Для вычисления корня используйте метод `sqrt()`,
// например `v.sqrt()`.


fn magnitude(...) -> f64 {
    todo!()
}

// Нормализуйте вектор: вычислите его длину и разделите все его координаты
// на эту длину.


fn normalize(...) {
    todo!()
}

// Проверьте свою работу с помощью следующей `main`.

fn main() {
    println!("Magnitude of a unit vector: {}", magnitude(&[0.0, 1.0, 0.0]));

    let mut v = [1.0, 2.0, 9.0];
    println!("Magnitude of {v:?}: {}", magnitude(&v));
    normalize(&mut v);
    println!("Magnitude of {v:?} after normalization: {}", magnitude(&v));
}

Решение

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Вычисляет длину заданного вектора.
fn magnitude(vector: &[f64; 3]) -> f64 {
    let mut mag_squared = 0.0;
    for coord in vector {
        mag_squared += coord * coord;
    }
    mag_squared.sqrt()
}

/// Меняет длину вектора на 1.0, не меняя его направления.
fn normalize(vector: &mut [f64; 3]) {
    let mag = magnitude(vector);
    for item in vector {
        *item /= mag;
    }
}

fn main() {
    println!("Magnitude of a unit vector: {}", magnitude(&[0.0, 1.0, 0.0]));

    let mut v = [1.0, 2.0, 9.0];
    println!("Magnitude of {v:?}: {}", magnitude(&v));
    normalize(&mut v);
    println!("Magnitude of {v:?} after normalization: {}", magnitude(&v));
}
  • Обратите внимание, что в normalize мы можем написать *item /= mag, чтобы изменить каждый элемент. Это возможно, потому что мы перебираем массив через изменяемую ссылку, и тогда цикл for выдаёт изменяемую ссылку на каждый элемент.

  • Здесь также можно взять ссылку на срез, например fn magnitude(vector: &[f64]) -> f64. Это делает функцию более общей ценой проверки длины во время выполнения.

Определяемые пользователем типы

Этот сегмент займёт около 1 часа. Он включает:

СлайдДлительность
Именованные структуры10 минут
Кортежи10 минут
Перечисления5 минут
Псевдонимы типов2 минуты
Константы10 минут
Статические объекты5 минут
Упражнение: Управление лифтом15 минут

Именованные структуры

Как и C и C++, Rust поддерживает пользовательские структуры:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct Person {
    name: String,
    age: u8,
}

fn describe(person: &Person) {
    println!("{} is {} years old", person.name, person.age);
}

fn main() {
    let mut peter = Person {
        name: String::from("Peter"),
        age: 27,
    };
    describe(&peter);

    peter.age = 28;
    describe(&peter);

    let name = String::from("Avery");
    let age = 39;
    let avery = Person { name, age };
    describe(&avery);
}
This slide should take about 10 minutes.

Ключевые моменты:

  • Структуры работают так же, как в C или C++.
    • Как и в C++, в отличие от C, для определения типа не нужен typedef.
    • В отличие от C++, между структурами нет наследования.
  • Сейчас хороший момент сообщить, что существуют разные виды структур.
    • Структуры нулевого размера (например, struct Foo;) могут пригодиться, когда мы реализуем трейт для какого-то типа, но самому значению не нужно хранить никаких данных.
    • На следующем слайде мы представим кортежные структуры, которые используются, когда имена полей не важны.
  • Если у вас уже есть переменные с нужными именами, структуру можно создать с помощью сокращённой записи.
  • Поля структур не поддерживают значения по умолчанию. Значения по умолчанию задаются реализацией трейта Default, о котором мы поговорим позже.

Для дальнейшего изучения

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

    // Copyright 2023 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    let jackie = Person { name: String::from("Jackie"), ..avery };
  • Он позволяет скопировать большую часть полей из старой структуры, не перечисляя их все явно. Он всегда должен быть последним элементом.

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

Кортежи

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct Point(i32, i32);

fn main() {
    let p = Point(17, 23);
    println!("({}, {})", p.0, p.1);
}

Так часто делают обёртки над одним полем (их называют newtype):

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct PoundsOfForce(f64);
struct Newtons(f64);

fn compute_thruster_force() -> PoundsOfForce {
    todo!("Ask a rocket scientist at NASA")
}

fn set_thruster_force(force: Newtons) {
    // ...
}

fn main() {
    let force = compute_thruster_force();
    set_thruster_force(force);
}
This slide should take about 10 minutes.
  • Newtype — отличный способ закодировать дополнительную информацию о значении примитивного типа, например:
    • Число измеряется в каких-то единицах: в примере выше это Newtons.
    • Значение прошло проверку при создании, поэтому больше не нужно проверять его при каждом использовании: PhoneNumber(String) или OddNumber(u32).
  • Паттерн newtype подробно рассматривается в модуле «Идиоматичный Rust».
  • Покажите, как прибавить значение f64 к типу Newtons, обратившись к единственному полю newtype.
    • Rust в целом избегает неявных преобразований, например автоматической распаковки или использования булевых значений как целых.
  • Если у кортежной структуры нет полей, () можно опустить. Результатом будет тип нулевого размера (ZST), у которого есть ровно одно значение (имя типа).
    • Это распространено для типов, которые реализуют некоторое поведение, но не содержат данных (представьте NullReader, который реализует поведение читателя, всегда возвращая EOF).
  • Этот пример — тонкая отсылка к аварии Mars Climate Orbiter.

Перечисления

Ключевое слово enum позволяет создать тип, у которого есть несколько разных вариантов:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug)]
enum Direction {
    Left,
    Right,
}

#[derive(Debug)]
enum PlayerMove {
    Pass,                        // Простой вариант
    Run(Direction),              // Вариант-кортеж
    Teleport { x: u32, y: u32 }, // Вариант-структура
}

fn main() {
    let dir = Direction::Left;
    let player_move: PlayerMove = PlayerMove::Run(dir);
    println!("On this turn: {player_move:?}");
}
This slide should take about 5 minutes.

Ключевые моменты:

  • Перечисления позволяют объединить набор значений в один тип.
  • Direction — это тип с вариантами. У Direction есть два значения: Direction::Left и Direction::Right.
  • PlayerMove — это тип с тремя вариантами. Помимо данных вариантов, Rust хранит дискриминант, чтобы во время выполнения знать, какой вариант находится в значении PlayerMove.
  • Сейчас хорошее время сравнить структуры и перечисления:
    • И там, и там можно иметь простую версию без полей (структура-единица) или версию с полями разных типов (данные вариантов).
    • Можно даже реализовать разные варианты перечисления отдельными структурами, но тогда они не будут одним и тем же типом, как если бы все они были определены в одном перечислении.
  • Rust использует минимум памяти для хранения дискриминанта.
    • При необходимости он хранит целое минимально необходимого размера.

    • Если допустимые значения вариантов не покрывают все битовые шаблоны, Rust использует недопустимые битовые шаблоны для кодирования дискриминанта (это «оптимизация ниш», niche optimization). Например, Option<&u8> хранит либо указатель на целое, либо NULL для варианта None.

    • При необходимости можно управлять дискриминантом (например, для совместимости с C):

      // Copyright 2023 Google LLC
      // SPDX-License-Identifier: Apache-2.0
      
      #[repr(u32)]
      enum Bar {
          A, // 0
          B = 10000,
          C, // 10001
      }
      
      fn main() {
          println!("A: {}", Bar::A as u32);
          println!("B: {}", Bar::B as u32);
          println!("C: {}", Bar::C as u32);
      }

      Без repr тип дискриминанта занимает 2 байта, так как 10001 помещается в 2 байта.

Для дальнейшего изучения

У Rust есть несколько оптимизаций, которые позволяют перечислениям занимать меньше места.

  • Оптимизация нулевого указателя: для некоторых типов Rust гарантирует, что size_of::<T>() равен size_of::<Option<T>>().

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

    // Copyright 2023 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    use std::mem::transmute;
    
    macro_rules! dbg_bits {
        ($e:expr, $bit_type:ty) => {
            println!("- {}: {:#x}", stringify!($e), transmute::<_, $bit_type>($e));
        };
    }
    
    fn main() {
        unsafe {
            println!("bool:");
            dbg_bits!(false, u8);
            dbg_bits!(true, u8);
    
            println!("Option<bool>:");
            dbg_bits!(None::<bool>, u8);
            dbg_bits!(Some(false), u8);
            dbg_bits!(Some(true), u8);
    
            println!("Option<Option<bool>>:");
            dbg_bits!(Some(Some(false)), u8);
            dbg_bits!(Some(Some(true)), u8);
            dbg_bits!(Some(None::<bool>), u8);
            dbg_bits!(None::<Option<bool>>, u8);
    
            println!("Option<&i32>:");
            dbg_bits!(None::<&i32>, usize);
            dbg_bits!(Some(&0i32), usize);
        }
    }

Псевдонимы типов

Псевдоним типа создаёт имя для другого типа. Оба типа можно использовать взаимозаменяемо.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

enum CarryableConcreteItem {
    Left,
    Right,
}

type Item = CarryableConcreteItem;

// Псевдонимы полезнее для длинных, сложных типов:
use std::cell::RefCell;
use std::sync::{Arc, RwLock};
type PlayerInventory = RwLock<Vec<Arc<RefCell<Item>>>>;
This slide should take about 2 minutes.
  • Newtype часто является лучшей альтернативой, так как создаёт отдельный тип. Предпочитайте struct InventoryCount(usize) вместо type InventoryCount = usize.

  • Программисты на C узнают в этом что-то похожее на typedef.

const

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

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

const DIGEST_SIZE: usize = 3;
const FILL_VALUE: u8 = calculate_fill_value();

const fn calculate_fill_value() -> u8 {
    if DIGEST_SIZE < 10 { 42 } else { 13 }
}

fn compute_digest(text: &str) -> [u8; DIGEST_SIZE] {
    let mut digest = [FILL_VALUE; DIGEST_SIZE];
    for (idx, &b) in text.as_bytes().iter().enumerate() {
        digest[idx % DIGEST_SIZE] = digest[idx % DIGEST_SIZE].wrapping_add(b);
    }
    digest
}

fn main() {
    let digest = compute_digest("Hello");
    println!("digest: {digest:?}");
}

Во время компиляции можно вызывать только функции, помеченные const, чтобы получить значения const. Однако функции const можно вызывать и во время выполнения.

This slide should take about 10 minutes.
  • Упомяните, что по смыслу const похож на constexpr из C++.

static

Статические переменные существуют на протяжении всего выполнения программы и поэтому не перемещаются:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

static BANNER: &str = "Добро пожаловать в RustOS 3.14";

fn main() {
    println!("{BANNER}");
}

Как отмечено в Rust RFC Book, при использовании они не подставляются, а имеют реальное связанное место в памяти. Это полезно для небезопасного и встраиваемого кода, а переменная живёт всё время выполнения программы. Если глобально определённому значению не нужна идентичность объекта, обычно предпочтительнее const.

This slide should take about 5 minutes.
  • static похож на изменяемые глобальные переменные в C++.
  • static обеспечивает идентичность объекта: адрес в памяти и состояние, которые нужны типам с внутренней изменяемостью, например Mutex<T>.

Для дальнейшего изучения

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

Часто в static используют OnceLock, чтобы поддержать инициализацию при первом использовании. OnceCell не реализует Sync, поэтому в этом контексте его использовать нельзя.

Данные, локальные для потока, можно создать с помощью макроса std::thread_local.

Упражнение: Управление лифтом

Мы создадим структуру данных, которая представляет событие в системе управления лифтом. Вы сами определите типы и функции для создания различных событий. Используйте #[derive(Debug)], чтобы типы можно было выводить через {:?}.

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#![allow(dead_code)]

#[derive(Debug)]
/// Событие в системе лифта, на которое должен реагировать контроллер.
enum Event {
    // TODO: добавьте необходимые варианты
}

/// Направление движения.
#[derive(Debug)]
enum Direction {
    Up,
    Down,
}

/// Кабина прибыла на указанный этаж.
fn car_arrived(floor: i32) -> Event {
    todo!()
}

/// Двери кабины открылись.
fn car_door_opened() -> Event {
    todo!()
}

/// Двери кабины закрылись.
fn car_door_closed() -> Event {
    todo!()
}

/// Была нажата кнопка направления в холле лифта на указанном этаже.
fn lobby_call_button_pressed(floor: i32, dir: Direction) -> Event {
    todo!()
}

/// Была нажата кнопка этажа в кабине лифта.
fn car_floor_button_pressed(floor: i32) -> Event {
    todo!()
}

fn main() {
    println!(
        "A ground floor passenger has pressed the up button: {:?}",
        lobby_call_button_pressed(0, Direction::Up)
    );
    println!("The car has arrived on the ground floor: {:?}", car_arrived(0));
    println!("The car door opened: {:?}", car_door_opened());
    println!(
        "A passenger has pressed the 3rd floor button: {:?}",
        car_floor_button_pressed(3)
    );
    println!("The car door closed: {:?}", car_door_closed());
    println!("The car has arrived on the 3rd floor: {:?}", car_arrived(3));
}
This slide and its sub-slides should take about 15 minutes.
  • Если студенты спросят про #![allow(dead_code)] в начале упражнения: он нужен, потому что единственное, что мы делаем с типом Event, — это выводим его. Из-за особенности проверки неиспользуемого кода компилятор считает код неиспользуемым. Для целей этого упражнения это можно игнорировать.

Решение

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#![allow(dead_code)]

#[derive(Debug)]
/// Событие в системе лифта, на которое должен реагировать контроллер.
enum Event {
    /// Была нажата кнопка.
    ButtonPressed(Button),

    /// Кабина прибыла на указанный этаж.
    CarArrived(Floor),

    /// Двери кабины открылись.
    CarDoorOpened,

    /// Двери кабины закрылись.
    CarDoorClosed,
}

/// Этаж представлен целым числом.
type Floor = i32;

/// Направление движения.
#[derive(Debug)]
enum Direction {
    Up,
    Down,
}

/// Кнопка, доступная пользователю.
#[derive(Debug)]
enum Button {
    /// Кнопка в холле лифта на указанном этаже.
    LobbyCall(Direction, Floor),

    /// Кнопка этажа внутри кабины.
    CarFloor(Floor),
}

/// Кабина прибыла на указанный этаж.
fn car_arrived(floor: i32) -> Event {
    Event::CarArrived(floor)
}

/// Двери кабины открылись.
fn car_door_opened() -> Event {
    Event::CarDoorOpened
}

/// Двери кабины закрылись.
fn car_door_closed() -> Event {
    Event::CarDoorClosed
}

/// Была нажата кнопка направления в холле лифта на указанном этаже.
fn lobby_call_button_pressed(floor: i32, dir: Direction) -> Event {
    Event::ButtonPressed(Button::LobbyCall(dir, floor))
}

/// Была нажата кнопка этажа в кабине лифта.
fn car_floor_button_pressed(floor: i32) -> Event {
    Event::ButtonPressed(Button::CarFloor(floor))
}

fn main() {
    println!(
        "A ground floor passenger has pressed the up button: {:?}",
        lobby_call_button_pressed(0, Direction::Up)
    );
    println!("The car has arrived on the ground floor: {:?}", car_arrived(0));
    println!("The car door opened: {:?}", car_door_opened());
    println!(
        "A passenger has pressed the 3rd floor button: {:?}",
        car_floor_button_pressed(3)
    );
    println!("The car door closed: {:?}", car_door_closed());
    println!("The car has arrived on the 3rd floor: {:?}", car_arrived(3));
}
  • Перечисления с данными: варианты перечислений Rust могут содержать данные. CarArrived(Floor) хранит целое число, а ButtonPressed(Button) хранит вложенное перечисление Button. Так Event может представлять богатый набор состояний безопасным для типов способом.
  • Псевдонимы типов: type Floor = i32 задаёт семантическое имя для i32. Это повышает читаемость, но для компилятора Floor по-прежнему остаётся просто i32.
  • #[derive(Debug)]: этот атрибут автоматически генерирует код, который форматирует перечисления при выводе через {:?}. Без него пришлось бы вручную реализовать трейт fmt::Debug.
  • Вложенные перечисления: перечисление Button вложено в Event::ButtonPressed. Такая иерархическая структура распространена в Rust для моделирования сложных предметных областей.
  • Обратите внимание, что Event::CarDoorOpened — это «вариант-единица» (он не содержит данных), а Event::CarArrived — «кортежный вариант».
  • Можно обсудить, почему Button — отдельное перечисление, а не просто варианты LobbyCallButtonPressed и CarFloorButtonPressed в Event. Оба подхода корректны, но группировка связанных понятий (например, кнопок) может сделать код чище.

Добро пожаловать во второй день

Мы рассмотрели основы Rust:

  • Базовые типы: целые, булевы значения, символы, кортежи и массивы.
  • Управление потоком: выражения if, циклы и выражения match.
  • Функции: как определять и вызывать функции.
  • Пользовательские типы: моделирование данных через struct и enum.
  • Ссылки: базовое заимствование через & и &mut.

Теперь вы можете построить любой тип в Rust и реализовать базовую логику!

Расписание

Включая 10-минутные перерывы, это занятие займёт около 2 часов 50 минут. Оно включает:

СегментДлительность
Добро пожаловать3 минуты
Шаблоны и сопоставление50 минут
Методы и трейты45 минут
Обобщения50 минут

Шаблоны и сопоставление

Этот сегмент займёт около 50 минут. Он включает:

СлайдДлительность
Неопровержимые шаблоны5 минут
Сопоставление10 минут
Деструктуризация структур4 минуты
Деструктуризация перечислений4 минуты
Let в потоке управления10 минут
Упражнение: Вычисление выражения15 минут

Неопровержимые шаблоны

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn takes_tuple(tuple: (char, i32, bool)) {
    let a = tuple.0;
    let b = tuple.1;
    let c = tuple.2;

    // Это делает то же самое, что и выше.
    let (a, b, c) = tuple;

    // Пропустить первый элемент, связать только второй и третий.
    let (_, b, c) = tuple;

    // Пропустить всё, кроме последнего элемента.
    let (.., c) = tuple;
}

fn main() {
    takes_tuple(('a', 777, true));
}
This slide should take about 5 minutes.
  • Все показанные шаблоны — неопровержимые (irrefutable), то есть они всегда совпадают со значением в правой части.

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

  • Имена переменных — это шаблоны, которые всегда совпадают и связывают сопоставленное значение с новой переменной с этим именем.

  • _ — шаблон, который всегда совпадает с любым значением и отбрасывает его.

  • .. позволяет проигнорировать сразу несколько значений.

Для дальнейшего изучения

  • Можно также показать более продвинутое использование .., например игнорирование средних элементов кортежа.

    #![allow(unused)]
    fn main() {
    // Copyright 2025 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    fn takes_tuple(tuple: (char, i32, bool, u8)) {
        let (first, .., last) = tuple;
    }
    }
  • Все эти шаблоны работают и с массивами:

    #![allow(unused)]
    fn main() {
    // Copyright 2025 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    fn takes_array(array: [u8; 5]) {
        let [first, .., last] = array;
    }
    }

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

Ключевое слово match позволяет сопоставить значение с одним или несколькими шаблонами. Шаблоны могут быть простыми значениями, как switch в C и C++, но также могут выражать более сложные условия:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[rustfmt::skip]
fn main() {
    let input = 'x';
    match input {
        'q'                       => println!("Quitting"),
        'a' | 's' | 'w' | 'd'     => println!("Moving around"),
        '0'..='9'                 => println!("Number input"),
        key if key.is_lowercase() => println!("Lowercase: {key}"),
        _                         => println!("Something else"),
    }
}

Переменная в шаблоне (здесь key) создаёт привязку, которую можно использовать внутри ветки match. Подробнее об этом мы узнаем на следующем слайде.

Охранник совпадения (match guard) делает так, что ветка срабатывает, только если условие истинно. Если условие ложно, match продолжит проверять последующие варианты.

This slide should take about 10 minutes.

Ключевые моменты:

  • Можно обратить внимание на то, как в шаблонах используются некоторые специальные символы.

    • | — как or
    • .. совпадает с любым количеством элементов
    • 1..=5 — включающий диапазон
    • _ — шаблон-подстановка
  • Охранники совпадения как отдельная возможность синтаксиса важны и нужны, когда мы хотим лаконично выразить более сложные идеи, чем позволяют одни только шаблоны.

  • Охранники совпадения отличаются от выражений if после =>. Выражение if вычисляется после того, как выбрана ветка match. Если условие if внутри этого блока ложно, другие ветки исходного выражения match не рассматриваются. В следующем примере шаблон-подстановка _ => даже не проверяется.

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[rustfmt::skip]
fn main() {
    let input = 'a';
    match input {
        key if key.is_uppercase() => println!("Uppercase"),
        key => if input == 'q' { println!("Quitting") },
        _   => println!("Bug: this is never printed"),
    }
}
  • Условие, заданное в охраннике, применяется к каждому выражению в шаблоне с |.

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

    #![allow(unused)]
    fn main() {
    // Copyright 2024 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    let expected = 5;
    match 123 {
        expected => println!("Expected value is 5, actual is {expected}"),
        _ => println!("Value was something else"),
    }
    }

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

Для дальнейшего изучения

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

    #![allow(unused)]
    fn main() {
    // Copyright 2024 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    let opt = Some(123);
    match opt {
        outer @ Some(inner) => {
            println!("outer: {outer:?}, inner: {inner}");
        }
        None => {}
    }
    }

    В этом примере inner имеет значение 123, которое извлечено из Option через деструктуризацию, а outer захватывает всё выражение Some(inner), поэтому содержит полный Option::Some(123). Это используется редко, но может быть полезно в более сложных шаблонах.

Структуры

Как и кортежи, структуры тоже можно деструктурировать через сопоставление:

// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct Move {
    delta: (i32, i32),
    repeat: u32,
}

#[rustfmt::skip]
fn main() {
    let m = Move { delta: (10, 0), repeat: 5 };

    match m {
        Move { delta: (0, 0), .. }        => println!("Standing still"),
        Move { delta: (x, 0), repeat }    => println!("{repeat} step x: {x}"),
        Move { delta: (0, y), repeat: 1 } => println!("Single step y: {y}"),
        _                                 => println!("Other move"),
    }
}
This slide should take about 4 minutes.
  • Измените буквальные значения в m, чтобы они совпали с другими шаблонами.
  • Добавьте новое поле в Movement и внесите необходимые изменения в шаблон.
  • Обратите внимание, что delta: (x, 0) — вложенный шаблон.

Для дальнейшего изучения

  • Попробуйте match &m и проверьте типы захватов. Синтаксис шаблона остаётся тем же, но захваты становятся разделяемыми ссылками. Это эргономика сопоставления, и она часто полезна с match self при реализации методов для перечисления.
    • Тот же эффект возникает с match &mut m: захваты становятся эксклюзивными ссылками.
  • Различие между захватом и константным выражением может быть трудно заметить. Попробуйте заменить 0 в первой ветке на переменную и посмотрите, что при этом незаметно перестаёт работать. Замените на const, и всё снова заработает.

Перечисления

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

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

// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

enum Result {
    Ok(i32),
    Err(String),
}

fn divide_in_two(n: i32) -> Result {
    if n % 2 == 0 {
        Result::Ok(n / 2)
    } else {
        Result::Err(format!("cannot divide {n} into two equal parts"))
    }
}

fn main() {
    let n = 100;
    match divide_in_two(n) {
        Result::Ok(half) => println!("{n} divided in two is {half}"),
        Result::Err(msg) => println!("sorry, an error happened: {msg}"),
    }
}

Здесь мы использовали ветки, чтобы деструктурировать значение Result. В первой ветке half связывается со значением внутри варианта Ok. Во второй ветке msg связывается с сообщением об ошибке.

This slide should take about 4 minutes.
  • Выражение if/else возвращает перечисление, которое затем распаковывается с помощью match.
  • Можно попробовать добавить третий вариант в определение перечисления и вывести ошибки при запуске кода. Укажите места, где код теперь не исчерпывающий, и как компилятор пытается подсказать решение.
  • Значения в вариантах перечисления можно получить только после сопоставления с образцом.
  • Покажите, что происходит, если поиск не исчерпывающий. Обратите внимание на преимущество компилятора Rust: он подтверждает, что все случаи обработаны.
  • Покажите синтаксис варианта в стиле структуры, добавив такой вариант в определение перечисления и в match. Укажите, что это синтаксически похоже на сопоставление со структурой.

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

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

  • Выражение if let
  • Выражение while let
  • Выражение let else

Выражения if let

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::time::Duration;

fn sleep_for(secs: f32) {
    let result = Duration::try_from_secs_f32(secs);

    if let Ok(duration) = result {
        std::thread::sleep(duration);
        println!("slept for {duration:?}");
    }
}

fn main() {
    sleep_for(-10.0);
    sleep_for(0.8);
}
  • if let может быть лаконичнее, чем match. Например, когда нужно проверить только один вариант. Выражение match, напротив, требует проверки всех вариантов.
  • Типичным случаем использования является проверка на значение Some при работе с типом Option.
  • В отличие от выражения match, if let не позволяет использовать охранные выражения.
  • С веткой else это можно использовать как выражение.

Операторы while let

Аналогично if let существует while let, который многократно проверяет значение на соответствие шаблону:

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let mut name = String::from("Comprehensive Rust 🦀");
    while let Some(c) = name.pop() {
        dbg!(c);
    }
    // (Есть более эффективные способы развернуть строку!)
}

Здесь String::pop возвращает Some(c), пока строка не опустеет, после чего вернёт None. Цикл while let позволяет последовательно перебрать все элементы.

  • Уточните, что циклы while let продолжаются, пока значение соответствует шаблону.
  • Цикл while let можно переписать как бесконечный цикл с оператором if, который прерывается, когда для name.pop() нет значения для распаковки. while let даёт синтаксический сахар для этого сценария.
  • Эту форму нельзя использовать как выражение, так как при ложном условии у неё может не быть значения.

Операторы let else

В распространённом случае, когда нужно сопоставить шаблон и вернуться из функции, используйте let else. Ветка else должна «расходиться» (return, break или паника, но не выход за конец блока).

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn hex_or_die_trying(maybe_string: Option<String>) -> Result<u32, String> {
    let s = if let Some(s) = maybe_string {
        s
    } else {
        return Err(String::from("got None"));
    };

    let first_byte_char = if let Some(first) = s.chars().next() {
        first
    } else {
        return Err(String::from("got empty string"));
    };

    let digit = if let Some(digit) = first_byte_char.to_digit(16) {
        digit
    } else {
        return Err(String::from("not a hex digit"));
    };

    Ok(digit)
}

fn main() {
    println!("result: {:?}", hex_or_die_trying(Some(String::from("foo"))));
}
The rewritten version is:
#![allow(unused)]
fn main() {
// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn hex_or_die_trying(maybe_string: Option<String>) -> Result<u32, String> {
    let Some(s) = maybe_string else {
        return Err(String::from("got None"));
    };

    let Some(first_byte_char) = s.chars().next() else {
        return Err(String::from("got empty string"));
    };

    let Some(digit) = first_byte_char.to_digit(16) else {
        return Err(String::from("not a hex digit"));
    };

    Ok(digit)
}
}

Для дальнейшего изучения

  • Такой поток управления с ранним возвратом типичен для кода обработки ошибок на Rust, где вы пытаетесь получить значение из Result и вернуть ошибку, если Result был Err.
  • Если студенты спросят, можно также показать, как тот же код обработки ошибок выглядит с использованием ?.

Упражнение: Вычисление выражения

Напишем простой рекурсивный вычислитель арифметических выражений.

Пример небольшого арифметического выражения — 10 + 20, которое вычисляется в 30. Выражение можно представить как дерево:

+1020

Более крупное и сложное выражение — (10 * 9) + ((3 - 4) * 5), которое вычисляется в 85. Мы представим его гораздо большим деревом:

+**109-534

В коде мы представим дерево двумя типами:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Операция, которую нужно выполнить над двумя подвыражениями.
#[derive(Debug)]
enum Operation {
    Add,
    Sub,
    Mul,
    Div,
}

/// Выражение в виде дерева.
#[derive(Debug)]
enum Expression {
    /// Операция над двумя подвыражениями.
    Op { op: Operation, left: Box<Expression>, right: Box<Expression> },

    /// Литеральное значение
    Value(i64),
}

Тип Box здесь — умный указатель, который будет подробно рассмотрен позже в курсе. Выражение можно «упаковать» в Box через Box::new, как показано в тестах. Чтобы вычислить упакованное выражение, используйте оператор разыменования (*), чтобы «распаковать» его: eval(*boxed_expr).

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

cargo new --lib evaluator

Скопируйте и вставьте код ниже в файл src/lib.rs.

Затем начните реализовывать eval. Используйте cargo test, чтобы убедиться, что итоговая библиотека проходит тесты. Может быть полезно использовать todo!() и добиваться прохождения тестов по одному. Тест можно временно пропустить с помощью #[ignore]:

#[test]
#[ignore]
fn test_value() { .. }
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Операция, которую нужно выполнить над двумя подвыражениями.
#[derive(Debug)]
enum Operation {
    Add,
    Sub,
    Mul,
    Div,
}

/// Выражение в виде дерева.
#[derive(Debug)]
enum Expression {
    /// Операция над двумя подвыражениями.
    Op { op: Operation, left: Box<Expression>, right: Box<Expression> },

    /// Литеральное значение
    Value(i64),
}

fn eval(e: Expression) -> i64 {
    todo!()
}

#[test]
fn test_value() {
    assert_eq!(eval(Expression::Value(19)), 19);
}

#[test]
fn test_sum() {
    assert_eq!(
        eval(Expression::Op {
            op: Operation::Add,
            left: Box::new(Expression::Value(10)),
            right: Box::new(Expression::Value(20)),
        }),
        30
    );
}

#[test]
fn test_recursion() {
    let term1 = Expression::Op {
        op: Operation::Mul,
        left: Box::new(Expression::Value(10)),
        right: Box::new(Expression::Value(9)),
    };
    let term2 = Expression::Op {
        op: Operation::Mul,
        left: Box::new(Expression::Op {
            op: Operation::Sub,
            left: Box::new(Expression::Value(3)),
            right: Box::new(Expression::Value(4)),
        }),
        right: Box::new(Expression::Value(5)),
    };
    assert_eq!(
        eval(Expression::Op {
            op: Operation::Add,
            left: Box::new(term1),
            right: Box::new(term2),
        }),
        85
    );
}

#[test]
fn test_zeros() {
    assert_eq!(
        eval(Expression::Op {
            op: Operation::Add,
            left: Box::new(Expression::Value(0)),
            right: Box::new(Expression::Value(0))
        }),
        0
    );
    assert_eq!(
        eval(Expression::Op {
            op: Operation::Mul,
            left: Box::new(Expression::Value(0)),
            right: Box::new(Expression::Value(0))
        }),
        0
    );
    assert_eq!(
        eval(Expression::Op {
            op: Operation::Sub,
            left: Box::new(Expression::Value(0)),
            right: Box::new(Expression::Value(0))
        }),
        0
    );
}

#[test]
fn test_div() {
    assert_eq!(
        eval(Expression::Op {
            op: Operation::Div,
            left: Box::new(Expression::Value(10)),
            right: Box::new(Expression::Value(2)),
        }),
        5
    )
}

Решение

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Операция, которую нужно выполнить над двумя подвыражениями.
#[derive(Debug)]
enum Operation {
    Add,
    Sub,
    Mul,
    Div,
}

/// Выражение в виде дерева.
#[derive(Debug)]
enum Expression {
    /// Операция над двумя подвыражениями.
    Op { op: Operation, left: Box<Expression>, right: Box<Expression> },

    /// Литеральное значение
    Value(i64),
}

fn eval(e: Expression) -> i64 {
    match e {
        Expression::Op { op, left, right } => {
            let left = eval(*left);
            let right = eval(*right);
            match op {
                Operation::Add => left + right,
                Operation::Sub => left - right,
                Operation::Mul => left * right,
                Operation::Div => left / right,
            }
        }
        Expression::Value(v) => v,
    }
}

#[test]
fn test_value() {
    assert_eq!(eval(Expression::Value(19)), 19);
}

#[test]
fn test_sum() {
    assert_eq!(
        eval(Expression::Op {
            op: Operation::Add,
            left: Box::new(Expression::Value(10)),
            right: Box::new(Expression::Value(20)),
        }),
        30
    );
}

#[test]
fn test_recursion() {
    let term1 = Expression::Op {
        op: Operation::Mul,
        left: Box::new(Expression::Value(10)),
        right: Box::new(Expression::Value(9)),
    };
    let term2 = Expression::Op {
        op: Operation::Mul,
        left: Box::new(Expression::Op {
            op: Operation::Sub,
            left: Box::new(Expression::Value(3)),
            right: Box::new(Expression::Value(4)),
        }),
        right: Box::new(Expression::Value(5)),
    };
    assert_eq!(
        eval(Expression::Op {
            op: Operation::Add,
            left: Box::new(term1),
            right: Box::new(term2),
        }),
        85
    );
}

#[test]
fn test_zeros() {
    assert_eq!(
        eval(Expression::Op {
            op: Operation::Add,
            left: Box::new(Expression::Value(0)),
            right: Box::new(Expression::Value(0))
        }),
        0
    );
    assert_eq!(
        eval(Expression::Op {
            op: Operation::Mul,
            left: Box::new(Expression::Value(0)),
            right: Box::new(Expression::Value(0))
        }),
        0
    );
    assert_eq!(
        eval(Expression::Op {
            op: Operation::Sub,
            left: Box::new(Expression::Value(0)),
            right: Box::new(Expression::Value(0))
        }),
        0
    );
}

#[test]
fn test_div() {
    assert_eq!(
        eval(Expression::Op {
            op: Operation::Div,
            left: Box::new(Expression::Value(10)),
            right: Box::new(Expression::Value(2)),
        }),
        5
    )
}

Методы и трейты

Этот сегмент займёт около 45 минут. Он включает:

СлайдДлительность
Методы10 минут
Трейты15 минут
Наследование3 минуты
Упражнение: трейт Logger15 минут

Методы

Rust позволяет связывать функции с вашими новыми типами. Это делается через блок impl:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug)]
struct CarRace {
    name: String,
    laps: Vec<i32>,
}

impl CarRace {
    // Без получателя, статический метод
    fn new(name: &str) -> Self {
        Self { name: String::from(name), laps: Vec::new() }
    }

    // Эксклюзивный заимствованный доступ на чтение и запись к self
    fn add_lap(&mut self, lap: i32) {
        self.laps.push(lap);
    }

    // Разделяемый заимствованный доступ только на чтение к self
    fn print_laps(&self) {
        println!("Recorded {} laps for {}:", self.laps.len(), self.name);
        for (idx, lap) in self.laps.iter().enumerate() {
            println!("Lap {idx}: {lap} sec");
        }
    }

    // Эксклюзивное владение self (рассмотрим позже)
    fn finish(self) {
        let total: i32 = self.laps.iter().sum();
        println!("Race {} is finished, total lap time: {}", self.name, total);
    }
}

fn main() {
    let mut race = CarRace::new("Monaco Grand Prix");
    race.add_lap(70);
    race.add_lap(68);
    race.print_laps();
    race.add_lap(71);
    race.print_laps();
    race.finish();
    // race.add_lap(42);
}

Аргументы self задают «получатель» (receiver) — объект, над которым работает метод. Для методов есть несколько распространённых видов получателей:

  • &self: заимствует объект у вызывающего кода через разделяемую неизменяемую ссылку. Объект можно использовать снова после этого.
  • &mut self: заимствует объект у вызывающего кода через уникальную изменяемую ссылку. Объект можно использовать снова после этого.
  • self: забирает владение объектом и перемещает его из вызывающего кода. Метод становится владельцем объекта. Объект будет освобождён (удалён из памяти) при возврате из метода, если только его владение явно не передано дальше. Полное владение автоматически не означает изменяемость.
  • mut self: то же, что и выше, но метод может изменять объект.
  • Без получателя: это становится статическим методом структуры. Обычно так создают конструкторы, которые по соглашению называются new.
This slide should take about 8 minutes.

Ключевые моменты:

  • Может помочь введение методов через сравнение с функциями.
    • Методы вызываются для экземпляра типа (например, структуры или перечисления); первый параметр представляет экземпляр как self.
    • Разработчики могут использовать методы, чтобы воспользоваться синтаксисом получателя метода и лучше организовать код. Используя методы, мы держим весь код реализации в одном предсказуемом месте.
    • Обратите внимание, что методы также можно вызывать как ассоциированные функции, явно передавая получатель, например CarRace::add_lap(&mut race, 20).
  • Укажите на использование ключевого слова self — получателя метода.
    • Покажите, что это сокращение для self: Self, и, возможно, покажите, как можно использовать и имя структуры.
    • Объясните, что Self — псевдоним типа, для которого написан блок impl, и его можно использовать в других местах этого блока.
    • Покажите, что self используется как и другие структуры, и что для отдельных полей можно использовать точечную нотацию.
    • Это хороший момент, чтобы показать, чем &self отличается от self, попробовав вызвать finish дважды.
    • Помимо вариантов self, получателями могут быть и специальные типы-обёртки, например Box<Self>.

Трейты

Rust позволяет абстрагироваться от типов с помощью трейтов. Они похожи на интерфейсы:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

trait Pet {
    /// Возвращает фразу, которую произносит этот питомец.
    fn talk(&self) -> String;

    /// Выводит в терминал строку-приветствие от этого питомца.
    fn greet(&self);
}
This slide and its sub-slides should take about 15 minutes.
  • Трейт определяет набор методов, которые должны быть у типа, чтобы реализовать этот трейт.

  • В следующем сегменте «Обобщения» мы увидим, как создать функциональность, обобщённую по всем типам, реализующим трейт.

Реализация трейтов

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

trait Pet {
    fn talk(&self) -> String;

    fn greet(&self) {
        println!("Oh you're a cutie! What's your name? {}", self.talk());
    }
}

struct Dog {
    name: String,
    age: i8,
}

impl Pet for Dog {
    fn talk(&self) -> String {
        format!("Woof, my name is {}!", self.name)
    }
}

fn main() {
    let fido = Dog { name: String::from("Fido"), age: 5 };
    dbg!(fido.talk());
    fido.greet();
}
  • Чтобы реализовать Trait для Type, используйте блок impl Trait for Type { .. }.

  • В отличие от интерфейсов Go, одного совпадения методов недостаточно: тип Cat с методом talk() не будет автоматически удовлетворять Pet, если он не находится в блоке impl Pet.

  • Трейты могут предоставлять реализации по умолчанию для некоторых методов. Реализации по умолчанию могут опираться на все методы трейта. В этом случае greet предоставлен и опирается на talk.

  • Для одного типа допускается несколько блоков impl: как собственных блоков impl, так и блоков impl трейтов. Аналогично, для одного типа можно реализовать несколько трейтов (и часто типы реализуют много трейтов!). Блоки impl даже могут быть разнесены по нескольким модулям или файлам.

Супертрейты

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

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

trait Animal {
    fn leg_count(&self) -> u32;
}

trait Pet: Animal {
    fn name(&self) -> String;
}

struct Dog(String);

impl Animal for Dog {
    fn leg_count(&self) -> u32 {
        4
    }
}

impl Pet for Dog {
    fn name(&self) -> String {
        self.0.clone()
    }
}

fn main() {
    let puppy = Dog(String::from("Rex"));
    println!("{} has {} legs", puppy.name(), puppy.leg_count());
}

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

Ассоциированные типы

Ассоциированные типы — это типы-заполнители, которые предоставляет реализация трейта.

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug)]
struct Meters(i32);
#[derive(Debug)]
struct MetersSquared(i32);

trait Multiply {
    type Output;
    fn multiply(&self, other: &Self) -> Self::Output;
}

impl Multiply for Meters {
    type Output = MetersSquared;
    fn multiply(&self, other: &Self) -> Self::Output {
        MetersSquared(self.0 * other.0)
    }
}

fn main() {
    println!("{:?}", Meters(10).multiply(&Meters(20)));
}
  • Ассоциированные типы иногда также называют «выходными типами». Ключевое наблюдение: этот тип выбирает реализующий, а не вызывающий код.

  • Многие трейты стандартной библиотеки имеют ассоциированные типы, включая арифметические операторы и Iterator.

Наследование

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug, Clone, Default)]
struct Player {
    name: String,
    strength: u8,
    hit_points: u8,
}

fn main() {
    let p1 = Player::default(); // Трейт Default добавляет конструктор `default`.
    let mut p2 = p1.clone(); // Трейт Clone добавляет метод `clone`.
    p2.name = String::from("EldurScrollz");
    // Трейт Debug добавляет поддержку вывода через `{:?}`.
    println!("{p1:?} vs. {p2:?}");
}
This slide should take about 3 minutes.
  • Производные реализации создаются с помощью макросов, и многие крейты предоставляют полезные производные макросы, которые добавляют полезную функциональность. Например, serde может сгенерировать поддержку сериализации для структуры через #[derive(Serialize)].

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

    // Copyright 2023 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    impl Clone for Player {
        fn clone(&self) -> Self {
            Player {
                name: self.name.clone(),
                strength: self.strength.clone(),
                hit_points: self.hit_points.clone(),
            }
        }
    }

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

Упражнение: трейт Logger

Давайте спроектируем простую утилиту логирования с трейтом Logger, у которого есть метод log. Код, который может записывать ход своей работы, затем может принимать &impl Logger. В тестах это может записывать сообщения в файл журнала тестов, а в рабочей (production) сборке — отправлять сообщения на сервер логов.

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

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

trait Logger {
    /// Записывает сообщение с заданным уровнем подробности.
    fn log(&self, verbosity: u8, message: &str);
}

struct StderrLogger;

impl Logger for StderrLogger {
    fn log(&self, verbosity: u8, message: &str) {
        eprintln!("verbosity={verbosity}: {message}");
    }
}

/// Записывает только сообщения до заданного уровня подробности.
struct VerbosityFilter {
    max_verbosity: u8,
    inner: StderrLogger,
}

// TODO: Реализуйте трейт `Logger` для `VerbosityFilter`.

fn main() {
    let logger = VerbosityFilter { max_verbosity: 3, inner: StderrLogger };
    logger.log(5, "FYI");
    logger.log(2, "Uhoh");
}

Решение

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

trait Logger {
    /// Записывает сообщение с заданным уровнем подробности.
    fn log(&self, verbosity: u8, message: &str);
}

struct StderrLogger;

impl Logger for StderrLogger {
    fn log(&self, verbosity: u8, message: &str) {
        eprintln!("verbosity={verbosity}: {message}");
    }
}

/// Записывает только сообщения до заданного уровня подробности.
struct VerbosityFilter {
    max_verbosity: u8,
    inner: StderrLogger,
}

impl Logger for VerbosityFilter {
    fn log(&self, verbosity: u8, message: &str) {
        if verbosity <= self.max_verbosity {
            self.inner.log(verbosity, message);
        }
    }
}

fn main() {
    let logger = VerbosityFilter { max_verbosity: 3, inner: StderrLogger };
    logger.log(5, "FYI");
    logger.log(2, "Uhoh");
}

Обобщения

Этот сегмент займёт около 50 минут. Он включает:

СлайдДлительность
Обобщённые функции5 минут
Ограничение трейта10 минут
Обобщённые типы данных10 минут
Обобщённые трейты5 минут
Синтаксис impl5 минут
dyn Trait5 минут
Упражнение: Обобщение min10 минут

Обобщённые функции

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn pick<T>(cond: bool, left: T, right: T) -> T {
    if cond { left } else { right }
}

fn main() {
    println!("picked a number: {:?}", pick(true, 222, 333));
    println!("picked a string: {:?}", pick(false, 'L', 'R'));
}
This slide should take about 5 minutes.
  • Может быть полезно показать мономорфизированные версии pick — либо до разговора об обобщённом pick, чтобы показать, как обобщения уменьшают дублирование кода, либо после разговора об обобщениях, чтобы показать, как работает мономорфизация.

    #![allow(unused)]
    fn main() {
    // Copyright 2023 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    fn pick_i32(cond: bool, left: i32, right: i32) -> i32 {
        if cond { left } else { right }
    }
    
    fn pick_char(cond: bool, left: char, right: char) -> char {
        if cond { left } else { right }
    }
    }
  • Rust выводит тип для T на основе типов аргументов и возвращаемого значения.

  • В этом примере для T мы используем только примитивные типы i32 и char, но здесь можно использовать любой тип, в том числе пользовательские:

    // Copyright 2023 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    struct Foo {
        val: u8,
    }
    
    pick(false, Foo { val: 7 }, Foo { val: 99 });
  • Это похоже на шаблоны C++, но Rust частично компилирует обобщённую функцию сразу, поэтому функция должна быть корректна для всех типов, удовлетворяющих ограничениям. Например, попробуйте изменить pick, чтобы он возвращал left + right, если cond ложно. Даже если используется только версия pick для целых чисел, Rust всё равно считает код некорректным. C++ позволил бы сделать это.

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

Ограничение трейта

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

Это можно сделать с помощью T: Trait:

// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn duplicate<T: Clone>(a: T) -> (T, T) {
    (a.clone(), a.clone())
}

struct NotCloneable;

fn main() {
    let foo = String::from("foo");
    let pair = duplicate(foo);
    println!("{pair:?}");
}
This slide should take about 8 minutes.
  • Попробуйте создать NotCloneable и передать его в duplicate.

  • Когда нужно несколько трейтов, соедините их через +.

  • Покажите предложение where: студенты встретятся с ним при чтении кода.

    // Copyright 2022 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    fn duplicate<T>(a: T) -> (T, T)
    where
        T: Clone,
    {
        (a.clone(), a.clone())
    }
    • Оно разгружает сигнатуру функции, если параметров много.
    • У него есть дополнительные возможности, которые делают его мощнее.
      • Если кто-то спросит, дополнительная возможность в том, что тип слева от «:» может быть произвольным, например Option<T>.
  • Обратите внимание, что Rust пока не поддерживает специализацию. Например, при исходной duplicate добавить специализированную duplicate(a: u32) некорректно.

Обобщённые типы данных

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub trait Logger {
    /// Записывает сообщение с заданным уровнем подробности.
    fn log(&self, verbosity: u8, message: &str);
}

struct StderrLogger;

impl Logger for StderrLogger {
    fn log(&self, verbosity: u8, message: &str) {
        eprintln!("[stderr] verbosity={verbosity}: {message}");
    }
}

struct StdoutLogger;

impl Logger for StdoutLogger {
    fn log(&self, verbosity: u8, message: &str) {
        println!("[stdout] verbosity={verbosity}: {message}");
    }
}

/// Записывает только сообщения до заданного уровня подробности.
struct VerbosityFilter<L> {
    max_verbosity: u8,
    inner: L,
}

impl<L: Logger> Logger for VerbosityFilter<L> {
    fn log(&self, verbosity: u8, message: &str) {
        if verbosity <= self.max_verbosity {
            self.inner.log(verbosity, message);
        }
    }
}

fn main() {
    let err = VerbosityFilter { max_verbosity: 3, inner: StderrLogger };
    err.log(5, "FYI");
    err.log(2, "Uhoh");

    let out = VerbosityFilter { max_verbosity: 3, inner: StdoutLogger };
    out.log(5, "FYI");
    out.log(2, "Uhoh");
}
This slide should take about 10 minutes.
  • Вопрос: Почему L указан дважды в impl<L: Logger> .. VerbosityFilter<L>? Разве это не избыточно?
    • Это потому, что это обобщённый блок реализации для обобщённого типа. Они независимо обобщены.
    • Это означает, что эти методы определены для любого L.
    • Можно написать impl VerbosityFilter<StderrLogger> { .. }.
      • VerbosityFilter по-прежнему обобщён, и можно использовать VerbosityFilter<f64>, но методы в этом блоке будут доступны только для VerbosityFilter<StderrLogger>.
  • Обратите внимание, что мы не задаём ограничение трейта для самого типа VerbosityFilter. Туда тоже можно поставить ограничения, но в Rust обычно ограничения трейтов указывают только в блоках impl.

Обобщённые трейты

Трейты, как и типы и функции, тоже могут быть обобщёнными. Параметры трейта получают конкретные типы при использовании. Например, трейт From<T> используется для определения преобразований типов:

#![allow(unused)]
fn main() {
// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub trait From<T>: Sized {
    fn from(value: T) -> Self;
}
}
// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug)]
struct Foo(String);

impl From<u32> for Foo {
    fn from(from: u32) -> Foo {
        Foo(format!("Converted from integer: {from}"))
    }
}

impl From<bool> for Foo {
    fn from(from: bool) -> Foo {
        Foo(format!("Converted from bool: {from}"))
    }
}

fn main() {
    let from_int = Foo::from(123);
    let from_bool = Foo::from(true);
    dbg!(from_int);
    dbg!(from_bool);
}
This slide should take about 5 minutes.
  • Трейт From будет рассмотрен позже в курсе, но его определение в документации std простое, и для справки оно приведено здесь.

  • Реализации трейта не обязаны покрывать все возможные параметры типа. Здесь Foo::from("hello") не скомпилируется, потому что для Foo нет реализации From<&str>.

  • Обобщённые трейты принимают типы как «входные» данные, тогда как ассоциированные типы — это «выходные» типы. Трейт может иметь несколько реализаций для разных входных типов.

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

Синтаксис impl

Как и ограничения трейтов, синтаксис impl Trait можно использовать в аргументах функций и в возвращаемых значениях:

// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

// Синтаксический сахар для:
//   fn add_42_millions<T: Into<i32>>(x: T) -> i32 {
fn add_42_millions(x: impl Into<i32>) -> i32 {
    x.into() + 42_000_000
}

fn pair_of(x: u32) -> impl std::fmt::Debug {
    (x + 1, x - 1)
}

fn main() {
    let many = add_42_millions(42_i8);
    dbg!(many);
    let many_more = add_42_millions(10_000_000);
    dbg!(many_more);
    let debuggable = pair_of(27);
    dbg!(debuggable);
}
This slide should take about 5 minutes.

impl Trait позволяет работать с типами, которые нельзя назвать. Значение impl Trait немного отличается в разных позициях.

  • Для параметра impl Trait похож на анонимный обобщённый параметр с ограничением трейта.

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

    В позиции возвращаемого значения вывод типов сложнее. Функция, возвращающая impl Foo, сама выбирает конкретный возвращаемый тип, не записывая его явно в исходном коде. Функция с обобщённым типом возврата вроде collect<B>() -> B может вернуть любой тип, удовлетворяющий B, и вызывающему коду может понадобиться его выбрать, например через let x: Vec<_> = foo.collect() или с помощью turbofish: foo.collect::<Vec<_>>().

Какой тип у debuggable? Попробуйте let debuggable: () = .., чтобы посмотреть, что покажет сообщение об ошибке.

dyn Trait

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

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct Dog {
    name: String,
    age: i8,
}
struct Cat {
    lives: i8,
}

trait Pet {
    fn talk(&self) -> String;
}

impl Pet for Dog {
    fn talk(&self) -> String {
        format!("Woof, my name is {}!", self.name)
    }
}

impl Pet for Cat {
    fn talk(&self) -> String {
        String::from("Miau!")
    }
}

// Использует обобщения и статическую диспетчеризацию.
fn generic(pet: &impl Pet) {
    println!("Hello, who are you? {}", pet.talk());
}

// Использует стирание типов и динамическую диспетчеризацию.
fn dynamic(pet: &dyn Pet) {
    println!("Hello, who are you? {}", pet.talk());
}

fn main() {
    let cat = Cat { lives: 9 };
    let dog = Dog { name: String::from("Fido"), age: 5 };

    generic(&cat);
    generic(&dog);

    dynamic(&cat);
    dynamic(&dog);
}
This slide should take about 5 minutes.
  • Обобщения, в том числе impl Trait, используют мономорфизацию: создают специализированную версию функции для каждого конкретного типа, с которым инстанцируется обобщение. Это значит, что вызов метода трейта из обобщённой функции всё равно использует статическую диспетчеризацию, так как компилятор имеет полную информацию о типе и может найти нужную реализацию трейта.

  • При использовании dyn Trait вызовы идут через динамическую диспетчеризацию с помощью таблицы виртуальных методов (vtable). Это значит, что есть единственная версия fn dynamic, которая используется независимо от того, какой тип Pet передан.

  • При использовании dyn Trait объект трейта должен находиться за каким-то видом косвенности. В данном случае это ссылка, хотя можно использовать и типы умных указателей, например Box (это будет показано в третий день).

  • Во время выполнения &dyn Pet представлен как «толстый указатель» (fat pointer), то есть пара из двух указателей: один указывает на конкретный объект, реализующий Pet, а другой — на vtable реализации трейта для этого типа. При вызове метода talk у &dyn Pet компилятор находит указатель на функцию talk в vtable и вызывает функцию, передавая в неё указатель на Dog или Cat. Компилятору не нужно знать конкретный тип Pet, чтобы это сделать.

  • Тип dyn Trait считается «стёртым по типу», потому что у нас больше нет знания о конкретном типе на этапе компиляции.

Упражнение: Обобщение min

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::cmp::Ordering;

// TODO: реализуйте функцию `min`, которая используется в тестах.

#[test]
fn integers() {
    assert_eq!(min(0, 10), 0);
    assert_eq!(min(500, 123), 123);
}

#[test]
fn chars() {
    assert_eq!(min('a', 'z'), 'a');
    assert_eq!(min('7', '1'), '1');
}

#[test]
fn strings() {
    assert_eq!(min("hello", "goodbye"), "goodbye");
    assert_eq!(min("bat", "armadillo"), "armadillo");
}
This slide and its sub-slides should take about 10 minutes.
  • Покажите студентам трейт Ord и перечисление Ordering.

Решение

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::cmp::Ordering;

fn min<T: Ord>(l: T, r: T) -> T {
    match l.cmp(&r) {
        Ordering::Less | Ordering::Equal => l,
        Ordering::Greater => r,
    }
}

#[test]
fn integers() {
    assert_eq!(min(0, 10), 0);
    assert_eq!(min(500, 123), 123);
}

#[test]
fn chars() {
    assert_eq!(min('a', 'z'), 'a');
    assert_eq!(min('7', '1'), '1');
}

#[test]
fn strings() {
    assert_eq!(min("hello", "goodbye"), "goodbye");
    assert_eq!(min("bat", "armadillo"), "armadillo");
}

Добро пожаловать

Включая 10-минутные перерывы, это занятие займёт около 2 часов 50 минут. Оно включает:

СегментДлительность
Замыкания30 минут
Стандартная библиотека: Типы1 час
Стандартная библиотека: Трейты1 час

Замыкания

Этот сегмент займёт около 30 минут. Он включает:

СлайдДлительность
Синтаксис замыканий3 минуты
Захват переменных5 минут
Трейты замыканий10 минут
Упражнение: фильтр логов10 минут

Синтаксис замыканий

Замыкания создаются с помощью вертикальных черт: |..| ...

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    // Тип аргумента и возвращаемый тип могут быть выведены в облегчённом синтаксисе:
    let double_it = |n| n * 2;
    dbg!(double_it(50));

    // Или можно явно указать типы и заключить тело в фигурные скобки:
    let add_1f32 = |x: f32| -> f32 { x + 1.0 };
    dbg!(add_1f32(50.));
}
This slide should take about 3 minutes.
  • Аргументы помещаются между |..|. Тело можно заключить в { .. }, но если это одно выражение, скобки можно опустить.

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

  • Оба примера можно записать и как обычные вложенные функции: они не захватывают никаких переменных из своего лексического окружения. Захваты мы рассмотрим дальше.

Для дальнейшего изучения

  • Возможность хранить функции в переменных относится не только к замыканиям: обычные функции тоже можно поместить в переменные и вызывать так же, как замыкания: пример на playground.

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

Захват переменных

Замыкание может захватывать переменные из окружения, в котором оно определено.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let max_value = 5;
    let clamp = |v| {
        if v > max_value { max_value } else { v }
    };

    dbg!(clamp(1));
    dbg!(clamp(3));
    dbg!(clamp(5));
    dbg!(clamp(7));
    dbg!(clamp(10));
}
This slide should take about 5 minutes.
  • По умолчанию замыкание захватывает значения по ссылке. Здесь max_value захватывается clamp, но по-прежнему доступна main для вывода. Попробуйте сделать max_value изменяемой, изменить её и снова вывести ограниченные значения. Почему это не работает?

  • Если замыкание изменяет значения, оно захватывает их по изменяемой ссылке. Попробуйте добавить max_value += 1 в clamp.

  • Можно заставить замыкание перемещать значения вместо того, чтобы брать на них ссылки, с помощью ключевого слова move. Это может помочь с временами жизни, например если замыкание должно пережить захваченные значения (подробнее о временах жизни позже).

    Выглядит это так: move |v| ... Попробуйте добавить это ключевое слово и посмотрите, сможет ли main по-прежнему обращаться к max_value после определения clamp.

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

Трейты замыканий

Замыкания, или лямбда-выражения, имеют типы, которые нельзя назвать. Однако они реализуют специальные трейты Fn, FnMut и FnOnce:

Специальные типы fn(..) -> T обозначают указатели на функции: либо адрес функции, либо замыкание, которое ничего не захватывает.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn apply_and_log(
    func: impl FnOnce(&'static str) -> String,
    func_name: &'static str,
    input: &'static str,
) {
    println!("Calling {func_name}({input}): {}", func(input))
}

fn main() {
    let suffix = "-itis";
    let add_suffix = |x| format!("{x}{suffix}");
    apply_and_log(&add_suffix, "add_suffix", "senior");
    apply_and_log(&add_suffix, "add_suffix", "appendix");

    let mut v = Vec::new();
    let mut accumulate = |x| {
        v.push(x);
        v.join("/")
    };
    apply_and_log(&mut accumulate, "accumulate", "red");
    apply_and_log(&mut accumulate, "accumulate", "green");
    apply_and_log(&mut accumulate, "accumulate", "blue");

    let take_and_reverse = |prefix| {
        let mut acc = String::from(prefix);
        acc.push_str(&v.into_iter().rev().collect::<Vec<_>>().join("/"));
        acc
    };
    apply_and_log(take_and_reverse, "take_and_reverse", "reversed: ");
}
This slide should take about 10 minutes.

Fn (например, add_suffix) не потребляет и не изменяет захваченные значения. Его можно вызывать, требуя только разделяемой ссылки на замыкание, поэтому замыкание можно выполнять многократно и даже одновременно.

FnMut (например, accumulate) может изменять захваченные значения. Объект замыкания доступен через эксклюзивную ссылку, поэтому его можно вызывать многократно, но не одновременно.

Если у вас есть FnOnce (например, take_and_reverse), его можно вызвать только один раз. Вызов потребляет замыкание и значения, захваченные перемещением.

FnMut является подтипом FnOnce, а Fn — подтипом FnMut и FnOnce. То есть там, где ожидается вызов FnOnce, можно использовать FnMut, а там, где ожидается FnMut или FnOnce, можно использовать Fn.

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

Наоборот, когда у вас есть замыкание, наибольшую гибкость даёт Fn (его можно передать в любой из трёх трейтов замыканий), затем FnMut, и, наконец, FnOnce.

Компилятор также выводит Copy (например, для add_suffix) и Clone (например, для take_and_reverse) в зависимости от того, что захватывает замыкание. Указатели на функции (ссылки на элементы fn) реализуют Copy и Fn.

Упражнение: фильтр логов

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub trait Logger {
    /// Записывает сообщение с заданным уровнем подробности.
    fn log(&self, verbosity: u8, message: &str);
}

struct StderrLogger;

impl Logger for StderrLogger {
    fn log(&self, verbosity: u8, message: &str) {
        eprintln!("[stderr] verbosity={verbosity}: {message}");
    }
}

struct StdoutLogger;

impl Logger for StdoutLogger {
    fn log(&self, verbosity: u8, message: &str) {
        println!("[stdout] verbosity={verbosity}: {message}");
    }
}

// TODO: определите и реализуйте `Filter`.

fn main() {
    let prefix = "yikes";
    let max_verbosity = 3;

    // Логгер, который пишет в stderr и фильтрует по содержимому сообщений.
    let logger = Filter::new(StderrLogger, |_verbosity, msg| msg.contains(prefix));
    logger.log(5, "FYI");
    logger.log(1, "yikes, something went wrong");
    logger.log(2, "uhoh");

    // Логгер, который пишет в stdout и фильтрует по уровню подробности.
    let logger =
        Filter::new(StdoutLogger, |verbosity, _msg| verbosity <= max_verbosity);
    logger.log(5, "FYI");
    logger.log(1, "yikes, something went wrong");
    logger.log(2, "uhoh");
}

Решение

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub trait Logger {
    /// Записывает сообщение с заданным уровнем подробности.
    fn log(&self, verbosity: u8, message: &str);
}

struct StderrLogger;

impl Logger for StderrLogger {
    fn log(&self, verbosity: u8, message: &str) {
        eprintln!("[stderr] verbosity={verbosity}: {message}");
    }
}

struct StdoutLogger;

impl Logger for StdoutLogger {
    fn log(&self, verbosity: u8, message: &str) {
        println!("[stdout] verbosity={verbosity}: {message}");
    }
}

/// Записывает только сообщения, которые соответствуют предикату фильтрации.
struct Filter<L, P> {
    inner: L,
    predicate: P,
}

impl<L, P> Filter<L, P>
where
    P: Fn(u8, &str) -> bool,
{
    fn new(inner: L, predicate: P) -> Self {
        Self { inner, predicate }
    }
}

impl<L, P> Logger for Filter<L, P>
where
    L: Logger,
    P: Fn(u8, &str) -> bool,
{
    fn log(&self, verbosity: u8, message: &str) {
        if (self.predicate)(verbosity, message) {
            self.inner.log(verbosity, message);
        }
    }
}

fn main() {
    let prefix = "yikes";
    let max_verbosity = 3;

    // Логгер, который пишет в stderr и фильтрует по содержимому сообщений.
    let logger = Filter::new(StderrLogger, |_verbosity, msg| msg.contains(prefix));
    logger.log(5, "FYI");
    logger.log(1, "yikes, something went wrong");
    logger.log(2, "uhoh");

    // Логгер, который пишет в stdout и фильтрует по уровню подробности.
    let logger =
        Filter::new(StdoutLogger, |verbosity, _msg| verbosity <= max_verbosity);
    logger.log(5, "FYI");
    logger.log(1, "yikes, something went wrong");
    logger.log(2, "uhoh");
}
  • Хранение замыканий: чтобы сохранить замыкание в структуре, мы используем обобщённый параметр типа (здесь P). Это потому, что каждое замыкание в Rust имеет уникальный анонимный тип, который генерирует компилятор.
  • Ограничение Fn: ограничение P: Fn(u8, &str) -> bool сообщает компилятору, что P можно вызвать как функцию с указанными аргументами и возвращаемым типом. Мы используем Fn (а не FnMut или FnOnce), потому что log принимает &self, поэтому предикат можно использовать только неизменяемо.
  • Вызов полей: мы вызываем замыкание через (self.predicate)(...). Скобки вокруг self.predicate необходимы, чтобы отличить вызов метода с именем predicate от вызова самого поля.
  • Обсудите, почему нужен Fn. Если бы мы использовали FnMut, log пришлось бы принимать &mut self, что противоречит сигнатуре трейта Logger. Если бы мы использовали FnOnce, можно было бы записать только одно сообщение!
  • Блок impl для new тоже содержит ограничения. Хотя для самого определения структуры они строго не обязательны (ограничения можно ставить только на блоки impl, которые их используют), размещение их в new помогает выводу типов.

Стандартная библиотека: Типы

Этот сегмент займёт около 1 часа. Он включает:

СлайдДлительность
Стандартная библиотека3 минуты
Документация5 минут
Option10 минут
Result5 минут
String5 минут
Vec5 минут
HashMap5 минут
Упражнение: Счётчик20 минут

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

Стандартная библиотека

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

На самом деле в стандартной библиотеке Rust есть несколько уровней: core, alloc и std.

  • core включает самые базовые типы и функции, которые не зависят от libc, аллокатора и даже наличия операционной системы.
  • alloc включает типы, которым нужен глобальный аллокатор кучи, например Vec, Box и Arc.
  • Встраиваемые приложения на Rust обычно используют только core, а иногда и alloc.

Документация

В Rust есть обширная документация. Например:

  • Все подробности о циклах.
  • Примитивные типы, например u8.
  • Типы стандартной библиотеки, например Option или BinaryHeap.

Чтобы просмотреть документацию, используйте rustup doc --std или https://std.rs.

На самом деле вы можете документировать и собственный код:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Определяет, делится ли первый аргумент на второй.
///
/// Если второй аргумент равен нулю, результат --- false.
fn is_divisible_by(lhs: u32, rhs: u32) -> bool {
    if rhs == 0 {
        return false;
    }
    lhs % rhs == 0
}

Содержимое интерпретируется как Markdown. Все опубликованные крейты библиотек Rust автоматически документируются на docs.rs с помощью инструмента rustdoc. Документировать таким образом все общедоступные элементы API — идиоматичный подход.

Чтобы задокументировать элемент изнутри него (например, внутри модуля), используйте //! или /*! .. */, так называемые «внутренние doc-комментарии»:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

//! Этот модуль содержит функции, связанные с делимостью целых чисел.
This slide should take about 5 minutes.
  • Покажите студентам сгенерированную документацию для крейта rand на https://docs.rs/rand.

Option

Мы уже видели некоторое использование Option<T>. Он хранит либо значение типа T, либо ничего. Например, String::find возвращает Option<usize>.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let name = "Löwe 老虎 Léopard Gepardi";
    let mut position: Option<usize> = name.find('é');
    dbg!(position);
    assert_eq!(position.unwrap(), 14);
    position = name.find('Z');
    dbg!(position);
    assert_eq!(position.expect("Character not found"), 0);
}
This slide should take about 10 minutes.
  • Option широко используется не только в стандартной библиотеке.

  • unwrap вернёт значение из Option или вызовет панику. expect работает похоже, но принимает сообщение об ошибке.

    • Можно вызвать панику при None, но нельзя «случайно» забыть проверить None.
    • Когда что-то собирают на скорую руку, часто используют unwrap/expect повсюду, но рабочий код обычно обрабатывает None более аккуратно.
  • Многие методы Option, такие как insert и take, можно рассматривать как работу с контейнером, в котором есть место не больше чем для одного элемента. Методы с похожими названиями и поведением есть и у других типов-контейнеров.

  • «Оптимизация ниш» означает, что Option<T> обычно имеет тот же размер в памяти, что и T, если существует представление, которое не является допустимым значением T. Например, ссылка не может быть NULL, поэтому Option<&T> автоматически использует NULL для представления варианта None, и поэтому может храниться в той же памяти, что и &T.

Result

Result похож на Option, но указывает на успех или неудачу операции, каждое из которых представлено разным вариантом перечисления. Он обобщённый: Result<T, E>, где T используется в варианте Ok, а E появляется в варианте Err.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::fs::File;
use std::io::Read;

fn main() {
    let file: Result<File, std::io::Error> = File::open("diary.txt");
    match file {
        Ok(mut file) => {
            let mut contents = String::new();
            if let Ok(bytes) = file.read_to_string(&mut contents) {
                println!("Dear diary: {contents} ({bytes} bytes)");
            } else {
                println!("Could not read file content");
            }
        }
        Err(err) => {
            println!("The diary could not be opened: {err}");
        }
    }
}
This slide should take about 5 minutes.
  • Как и с Option, успешное значение находится внутри Result, и разработчик вынужден явно его извлечь. Это поощряет проверку ошибок. Если ошибка не должна случиться никогда, можно вызвать unwrap() или expect(), и это тоже сигнализирует о намерении разработчика.
  • Документацию Result рекомендуется прочитать. Не во время курса, но стоит упомянуть. Там много удобных методов и функций, которые помогают функциональному стилю программирования.
  • Result — стандартный тип для реализации обработки ошибок, как мы увидим в четвёртый день.

String

String — это растущая строка в кодировке UTF-8:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let mut s1 = String::new();
    s1.push_str("Hello");
    println!("s1: len = {}, capacity = {}", s1.len(), s1.capacity());

    let mut s2 = String::with_capacity(s1.len() + 1);
    s2.push_str(&s1);
    s2.push('!');
    println!("s2: len = {}, capacity = {}", s2.len(), s2.capacity());

    let s3 = String::from("🇨🇭");
    println!("s3: len = {}, number of chars = {}", s3.len(), s3.chars().count());
}

String реализует Deref<Target = str>, что означает, что на String можно вызывать все методы str.

This slide should take about 5 minutes.
  • String::new возвращает новую пустую строку; используйте String::with_capacity, когда знаете, сколько данных хотите добавить в строку.
  • String::len возвращает размер String в байтах (он может отличаться от длины в символах).
  • String::chars возвращает итератор по реальным символам. Обратите внимание, что char может отличаться от того, что человек считает «символом», из-за графемных кластеров.
  • Когда говорят о строках, имеют в виду либо &str, либо String.
  • Когда тип реализует Deref<Target = T>, компилятор позволит прозрачно вызывать методы T.
    • Мы ещё не обсуждали трейт Deref, поэтому пока это в основном объясняет структуру боковой панели в документации.
    • String реализует Deref<Target = str>, который прозрачно даёт ему доступ к методам str.
    • Напишите и сравните let s3 = s1.deref(); и let s3 = &*s1;.
  • String реализован как обёртка над вектором байтов. Многие операции, которые поддерживаются для векторов, работают и для String, но с некоторыми дополнительными гарантиями.
  • Сравните разные способы индексировать String:
    • По символу — с помощью s3.chars().nth(i).unwrap(), где i внутри границ и вне их.
    • По подстроке — с помощью s3[0..4], где срез выровнен по границам символов или нет.
  • Многие типы можно преобразовать в строку с помощью метода to_string. Этот трейт автоматически реализуется для всех типов, реализующих Display, поэтому всё, что можно отформатировать, можно и превратить в строку.

Vec

Vec — стандартный расширяемый буфер в куче:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let mut v1 = Vec::new();
    v1.push(42);
    println!("v1: len = {}, capacity = {}", v1.len(), v1.capacity());

    let mut v2 = Vec::with_capacity(v1.len() + 1);
    v2.extend(v1.iter());
    v2.push(9999);
    println!("v2: len = {}, capacity = {}", v2.len(), v2.capacity());

    // Канонический макрос для инициализации вектора элементами.
    let mut v3 = vec![0, 0, 1, 2, 3, 4];

    // Оставить только чётные элементы.
    v3.retain(|x| x % 2 == 0);
    println!("{v3:?}");

    // Удалить подряд идущие дубликаты.
    v3.dedup();
    println!("{v3:?}");
}

Vec реализует Deref<Target = [T]>, что означает, что на Vec можно вызывать методы срезов.

This slide should take about 5 minutes.
  • Vec — один из видов коллекций, наряду с String и HashMap. Содержащиеся в нём данные хранятся в куче. Это значит, что объём данных не нужно знать во время компиляции: он может расти или уменьшаться во время выполнения.
  • Обратите внимание, что Vec<T> тоже обобщённый тип, но T указывать явно не нужно. Как всегда в выводе типов Rust, T определился при первом вызове push.
  • vec![...] — канонический макрос, который используют вместо Vec::new(); он позволяет сразу добавить элементы в вектор.
  • Для индексации вектора используются [ ], но при выходе за границы они вызовут панику. Альтернативно get вернёт Option. Функция pop удаляет последний элемент.
  • Студенты могут спросить, почему у Vec и срезов так много собственных методов, а стандартная библиотека не предоставляет трейты-контейнеры. Основная причина такого дизайна в том, что Rust вместо этого централизует функциональность в трейте Iterator, который абстрагирует и потоки ввода-вывода, и коллекции, при этом избегая материализации промежуточных коллекций.

HashMap

Стандартная хеш-таблица с защитой от атак HashDoS:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::collections::HashMap;

fn main() {
    let mut page_counts = HashMap::new();
    page_counts.insert("Adventures of Huckleberry Finn", 207);
    page_counts.insert("Grimms' Fairy Tales", 751);
    page_counts.insert("Pride and Prejudice", 303);

    if !page_counts.contains_key("Les Misérables") {
        println!(
            "We know about {} books, but not Les Misérables.",
            page_counts.len()
        );
    }

    for book in ["Pride and Prejudice", "Alice's Adventure in Wonderland"] {
        match page_counts.get(book) {
            Some(count) => println!("{book}: {count} pages"),
            None => println!("{book} is unknown."),
        }
    }

    // Используйте метод .entry(), чтобы вставить значение, если ничего не найдено.
    for book in ["Pride and Prejudice", "Alice's Adventure in Wonderland"] {
        let page_count: &mut i32 = page_counts.entry(book).or_insert(0);
        *page_count += 1;
    }

    dbg!(page_counts);
}
This slide should take about 5 minutes.
  • HashMap не входит в prelude, поэтому её нужно импортировать в область видимости.

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

    // Copyright 2023 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    let pc1 = page_counts
        .get("Harry Potter and the Sorcerer's Stone")
        .unwrap_or(&336);
    let pc2 = page_counts
        .entry("The Hunger Games")
        .or_insert(374);
  • В отличие от vec!, к сожалению, стандартного макроса hashmap! нет.

    • Однако начиная с Rust 1.56 HashMap реализует From<[(K, V); N]>, что позволяет легко инициализировать хеш-таблицу из литерала массива:

      // Copyright 2023 Google LLC
      // SPDX-License-Identifier: Apache-2.0
      
      let page_counts = HashMap::from([
        ("Harry Potter and the Sorcerer's Stone".to_string(), 336),
        ("The Hunger Games".to_string(), 374),
      ]);
  • Также HashMap можно построить из любого Iterator, который выдаёт кортежи «ключ-значение».

  • Этот тип имеет несколько «специфичных для метода» возвращаемых типов, например std::collections::hash_map::Keys. Такие типы часто встречаются в поиске по документации Rust. Покажите студентам документацию по этому типу и полезную ссылку обратно на метод keys.

Упражнение: Счётчик

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

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

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::collections::HashMap;

/// Counter считает, сколько раз встретилось каждое значение типа T.
struct Counter {
    values: HashMap<u32, u64>,
}

impl Counter {
    /// Создаёт новый Counter.
    fn new() -> Self {
        Counter {
            values: HashMap::new(),
        }
    }

    /// Учитывает появление заданного значения.
    fn count(&mut self, value: u32) {
        if self.values.contains_key(&value) {
            *self.values.get_mut(&value).unwrap() += 1;
        } else {
            self.values.insert(value, 1);
        }
    }

    /// Возвращает, сколько раз встретилось заданное значение.
    fn times_seen(&self, value: u32) -> u64 {
        self.values.get(&value).copied().unwrap_or_default()
    }
}

fn main() {
    let mut ctr = Counter::new();
    ctr.count(13);
    ctr.count(14);
    ctr.count(16);
    ctr.count(14);
    ctr.count(14);
    ctr.count(11);

    for i in 10..20 {
        println!("saw {} values equal to {}", ctr.times_seen(i), i);
    }

    let mut strctr = Counter::new();
    strctr.count("apple");
    strctr.count("orange");
    strctr.count("apple");
    println!("got {} apples", strctr.times_seen("apple"));
}

Решение

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::collections::HashMap;
use std::hash::Hash;

/// Counter считает, сколько раз встретилось каждое значение типа T.
struct Counter<T> {
    values: HashMap<T, u64>,
}

impl<T: Eq + Hash> Counter<T> {
    /// Создаёт новый Counter.
    fn new() -> Self {
        Counter { values: HashMap::new() }
    }

    /// Учитывает появление заданного значения.
    fn count(&mut self, value: T) {
        *self.values.entry(value).or_default() += 1;
    }

    /// Возвращает, сколько раз встретилось заданное значение.
    fn times_seen(&self, value: T) -> u64 {
        self.values.get(&value).copied().unwrap_or_default()
    }
}

fn main() {
    let mut ctr = Counter::new();
    ctr.count(13);
    ctr.count(14);
    ctr.count(16);
    ctr.count(14);
    ctr.count(14);
    ctr.count(11);

    for i in 10..20 {
        println!("saw {} values equal to {}", ctr.times_seen(i), i);
    }

    let mut strctr = Counter::new();
    strctr.count("apple");
    strctr.count("orange");
    strctr.count("apple");
    println!("got {} apples", strctr.times_seen("apple"));
}

Стандартная библиотека: Трейты

Этот сегмент займёт около 1 часа. Он включает:

СлайдДлительность
Сравнения5 минут
Перегрузка операторов5 минут
From и Into5 минут
Преобразование типов5 минут
Read и Write5 минут
Default5 минут
Упражнение: Шифр ROT1330 минут

Как и с типами стандартной библиотеки, уделите время изучению документации к каждому трейту.

Этот раздел длинный. Сделайте перерыв на середине.

Сравнения

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

PartialEq и Eq

PartialEq — частичное отношение эквивалентности с обязательным методом eq и предоставляемым методом ne. Операторы == и != вызывают эти методы.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct Key {
    id: u32,
    metadata: Option<String>,
}
impl PartialEq for Key {
    fn eq(&self, other: &Self) -> bool {
        self.id == other.id
    }
}

Eq — полное отношение эквивалентности (рефлексивное, симметричное и транзитивное), и оно подразумевает PartialEq. Функции, которые требуют полной эквивалентности, используют Eq как ограничение трейта.

PartialOrd и Ord

PartialOrd задаёт частичный порядок с методом partial_cmp. Он используется для реализации операторов <, <=, >= и >.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::cmp::Ordering;
#[derive(Eq, PartialEq)]
struct Citation {
    author: String,
    year: u32,
}
impl PartialOrd for Citation {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        match self.author.partial_cmp(&other.author) {
            Some(Ordering::Equal) => self.year.partial_cmp(&other.year),
            author_ord => author_ord,
        }
    }
}

Ord — полный порядок, где cmp возвращает Ordering.

This slide should take about 5 minutes.
  • PartialEq можно реализовать между разными типами, а Eq нельзя, потому что он рефлексивен:

    // Copyright 2023 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    struct Key {
        id: u32,
        metadata: Option<String>,
    }
    impl PartialEq<u32> for Key {
        fn eq(&self, other: &u32) -> bool {
            self.id == *other
        }
    }
  • На практике эти трейты часто получают через derive, но реализуют их вручную редко.

  • При сравнении ссылок в Rust сравниваются значения того, на что они указывают, а НЕ сами ссылки. Поэтому ссылки на два разных объекта могут сравниваться как равные, если значения, на которые они указывают, одинаковы:

    // Copyright 2023 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    fn main() {
        let a = "Hello";
        let b = String::from("Hello");
        assert_eq!(a, b);
    }

Перегрузка операторов

Перегрузка операторов реализуется через трейты в std::ops:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug, Copy, Clone)]
struct Point {
    x: i32,
    y: i32,
}

impl std::ops::Add for Point {
    type Output = Self;

    fn add(self, other: Self) -> Self {
        Self { x: self.x + other.x, y: self.y + other.y }
    }
}

fn main() {
    let p1 = Point { x: 10, y: 20 };
    let p2 = Point { x: 100, y: 200 };
    println!("{p1:?} + {p2:?} = {:?}", p1 + p2);
}
This slide should take about 5 minutes.

Темы для обсуждения:

  • Вы могли бы реализовать Add для &Point. В каких ситуациях это полезно?
    • Ответ: Add::add забирает self. Если тип T, для которого перегружается оператор, не Copy, стоит рассмотреть перегрузку оператора и для &T. Это позволяет избежать лишнего клонирования в месте вызова.
  • Почему Output — ассоциированный тип? Можно ли сделать его параметром типа метода?
    • Коротко: параметры типа функции задаёт вызывающий код, а ассоциированные типы (например, Output) задаёт реализующий трейт.
  • Можно реализовать Add для двух разных типов, например impl Add<(i32, i32)> for Point добавит кортеж к Point.

Трейт Not (оператор !) примечателен тем, что не приводит аргумент к bool, как одноимённый оператор в языках семейства C; вместо этого для целых чисел он инвертирует каждый бит, что арифметически эквивалентно вычитанию аргумента из -1: !5 == -6.

From и Into

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let s = String::from("hello");
    let addr = std::net::Ipv4Addr::from([127, 0, 0, 1]);
    let one = i16::from(true);
    let bigger = i32::from(123_i16);
    println!("{s}, {addr}, {one}, {bigger}");
}

Into реализуется автоматически, когда реализован From:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let s: String = "hello".into();
    let addr: std::net::Ipv4Addr = [127, 0, 0, 1].into();
    let one: i16 = true.into();
    let bigger: i32 = 123_i16.into();
    println!("{s}, {addr}, {one}, {bigger}");
}
This slide should take about 5 minutes.
  • Поэтому часто реализуют только From: тогда у вашего типа появится и реализация Into.
  • Когда объявляете тип входного аргумента функции как «всё, что можно преобразовать в String», правило обратное: нужно использовать Into. Ваша функция будет принимать типы, которые реализуют From, и те, которые реализуют только Into.

Преобразование типов

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let value: i64 = 1000;
    println!("as u16: {}", value as u16);
    println!("as i16: {}", value as i16);
    println!("as u8: {}", value as u8);
}

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

Приведение через as — довольно острый инструмент, которым легко ошибиться, и он может стать источником тонких ошибок, когда при дальнейшей разработке меняются используемые типы или диапазоны значений. Приведения лучше использовать только тогда, когда намерение — безусловно отбросить старшие биты (например, выбрать младшие 32 бита u64 через as u32, независимо от того, что было в старших битах).

Для приведений, которые не могут завершиться ошибкой (например, u32 в u64), предпочтительнее использовать From или Into, а не as, чтобы подтвердить, что приведение действительно безошибочно. Для приведений, которые могут не сработать, доступны TryFrom и TryInto, если нужно по-разному обрабатывать значения, которые помещаются, и те, которые не помещаются.

This slide should take about 5 minutes.

Подумайте о перерыве после этого слайда.

as похож на статическое приведение (static cast) в C++. Использование as там, где данные могут потеряться, обычно не одобряется, или, по крайней мере, требует поясняющего комментария.

Это распространено при приведении целых к usize для использования в качестве индекса.

Read и Write

Используя Read и BufRead, можно абстрагироваться от источников u8:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::io::{BufRead, BufReader, Read, Result};

fn count_lines<R: Read>(reader: R) -> usize {
    let buf_reader = BufReader::new(reader);
    buf_reader.lines().count()
}

fn main() -> Result<()> {
    let slice: &[u8] = b"foo\nbar\nbaz\n";
    println!("lines in slice: {}", count_lines(slice));

    let file = std::fs::File::open(std::env::current_exe()?)?;
    println!("lines in file: {}", count_lines(file));
    Ok(())
}

Аналогично Write позволяет абстрагироваться от приёмников u8:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::io::{Result, Write};

fn log<W: Write>(writer: &mut W, msg: &str) -> Result<()> {
    writer.write_all(msg.as_bytes())?;
    writer.write_all("\n".as_bytes())
}

fn main() -> Result<()> {
    let mut buffer = Vec::new();
    log(&mut buffer, "Hello")?;
    log(&mut buffer, "World")?;
    println!("Logged: {buffer:?}");
    Ok(())
}

Трейт Default

Default задаёт значение по умолчанию для типа.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug, Default)]
struct Derived {
    x: u32,
    y: String,
    z: Implemented,
}

#[derive(Debug)]
struct Implemented(String);

impl Default for Implemented {
    fn default() -> Self {
        Self("John Smith".into())
    }
}

fn main() {
    let default_struct = Derived::default();
    dbg!(default_struct);

    let almost_default_struct =
        Derived { y: "Y is set!".into(), ..Derived::default() };
    dbg!(almost_default_struct);

    let nothing: Option<Derived> = None;
    dbg!(nothing.unwrap_or_default());
}
This slide should take about 5 minutes.
  • Его можно реализовать вручную или получить через #[derive(Default)].
  • Реализация, полученная через derive, создаёт значение, в котором все поля установлены в значения по умолчанию.
    • Это означает, что все типы в структуре тоже должны реализовывать Default.
  • Стандартные типы Rust обычно реализуют Default с разумными значениями (например, 0, "" и т. д.).
  • Частичная инициализация структуры хорошо сочетается с default.
  • Стандартная библиотека Rust знает, что типы могут реализовывать Default, и предоставляет удобные методы, которые его используют.
  • Синтаксис .. называется синтаксисом обновления структуры.

Упражнение: Шифр ROT13

В этом упражнении вы реализуете классический шифр «ROT13». Скопируйте код в playground и реализуйте недостающие части. Поворачивайте только буквы ASCII, чтобы результат оставался корректным UTF-8.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::io::Read;

struct RotDecoder<R: Read> {
    input: R,
    rot: u8,
}

// Реализуйте трейт `Read` для `RotDecoder`.

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn joke() {
        let mut rot =
            RotDecoder { input: "Gb trg gb gur bgure fvqr!".as_bytes(), rot: 13 };
        let mut result = String::new();
        rot.read_to_string(&mut result).unwrap();
        assert_eq!(&result, "To get to the other side!");
    }

    #[test]
    fn binary() {
        let input: Vec<u8> = (0..=255u8).collect();
        let mut rot = RotDecoder::<&[u8]> { input: input.as_slice(), rot: 13 };
        let mut buf = [0u8; 256];
        assert_eq!(rot.read(&mut buf).unwrap(), 256);
        for i in 0..=255 {
            if input[i] != buf[i] {
                assert!(input[i].is_ascii_alphabetic());
                assert!(buf[i].is_ascii_alphabetic());
            }
        }
    }
}

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

Решение

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::io::Read;

struct RotDecoder<R: Read> {
    input: R,
    rot: u8,
}

impl<R: Read> Read for RotDecoder<R> {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
        let size = self.input.read(buf)?;
        for b in &mut buf[..size] {
            if b.is_ascii_alphabetic() {
                let base = if b.is_ascii_uppercase() { 'A' } else { 'a' } as u8;
                *b = (*b - base + self.rot) % 26 + base;
            }
        }
        Ok(size)
    }
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn joke() {
        let mut rot =
            RotDecoder { input: "Gb trg gb gur bgure fvqr!".as_bytes(), rot: 13 };
        let mut result = String::new();
        rot.read_to_string(&mut result).unwrap();
        assert_eq!(&result, "To get to the other side!");
    }

    #[test]
    fn binary() {
        let input: Vec<u8> = (0..=255u8).collect();
        let mut rot = RotDecoder::<&[u8]> { input: input.as_slice(), rot: 13 };
        let mut buf = [0u8; 256];
        assert_eq!(rot.read(&mut buf).unwrap(), 256);
        for i in 0..=255 {
            if input[i] != buf[i] {
                assert!(input[i].is_ascii_alphabetic());
                assert!(buf[i].is_ascii_alphabetic());
            }
        }
    }
}

Добро пожаловать в третий день

Теперь мы рассмотрели все основные возможности языка:

  • Основы: базовые типы, управление потоком, функции и структуры данных.
  • Сопоставление с образцом: эффективная деструктуризация данных.
  • Полиморфизм: методы, трейты и обобщения.
  • Стандартная библиотека: использование основных типов, таких как Option, Result, Vec и String.
  • Замыкания: анонимные функции, которые могут захватывать окружение.

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

Расписание

Включая 10-минутные перерывы, это занятие займёт около 2 часов 20 минут. Оно включает:

СегментДлительность
Добро пожаловать3 минуты
Управление памятью1 час
Умные указатели55 минут

Управление памятью

Этот сегмент займёт около 1 часа. Он включает:

СлайдДлительность
Куча и стек5 минут
Подходы к управлению памятью10 минут
Владение5 минут
Перемещение5 минут
Clone2 минуты
Копируемые типы5 минут
Трейт Drop10 минут
Упражнение: Тип-построитель20 минут

Куча и стек

Программы выделяют память двумя способами:

  • Стек: непрерывная область памяти для локальных переменных.

    • Значения имеют фиксированный размер, известный во время компиляции.
    • Чрезвычайно быстро: достаточно сдвинуть указатель стека.
    • Легко управлять: следует за вызовами функций.
    • Отличная локальность памяти.
  • Куча: хранилище значений вне вызовов функций.

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

Пример

Создание String размещает метаданные фиксированного размера на стеке, а данные переменного размера, то есть саму строку, — в куче:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let s1 = String::from("Hello");
}
StackHeaps1capacity5ptrHellolen5
This slide should take about 5 minutes.
  • Отметьте, что String основан на Vec, поэтому у него есть ёмкость и длина, и он может расти (если изменяем) за счёт перевыделения памяти в куче.

  • Если студенты спросят, можно упомянуть, что базовая память выделяется в куче через системный аллокатор, а собственные аллокаторы можно реализовать через Allocator API.

Для дальнейшего изучения

Можно исследовать раскладку памяти с помощью unsafe Rust. Однако стоит подчеркнуть, что это действительно небезопасно!

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let mut s1 = String::from("Hello");
    s1.push(' ');
    s1.push_str("world");
    // НЕ ДЕЛАЙТЕ ТАК ДОМА! Только в учебных целях.
    // String не даёт никаких гарантий о своей раскладке, поэтому это может привести к
    // неопределённому поведению.
    unsafe {
        let (capacity, ptr, len): (usize, usize, usize) = std::mem::transmute(s1);
        println!("capacity = {capacity}, ptr = {ptr:#x}, len = {len}");
    }
}

Подходы к управлению памятью

Традиционно языки делятся на две широкие категории:

  • Полный контроль через ручное управление памятью: C, C++, Pascal, …
    • Программист решает, когда выделять или освобождать память в куче.
    • Программист должен определить, всё ещё ли указатель указывает на допустимую память.
    • Исследования показывают, что программисты допускают ошибки.
  • Полная безопасность через автоматическое управление памятью во время выполнения: Java, Python, Go, Haskell, …
    • Система времени выполнения гарантирует, что память не освобождается, пока на неё можно ссылаться.
    • Обычно реализуется подсчётом ссылок или сборкой мусора.

Rust предлагает новую комбинацию:

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

Это достигается с помощью явной концепции владения.

This slide should take about 10 minutes.

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

  • В C кучей нужно управлять вручную с помощью malloc и free. Распространённые ошибки включают забывание вызвать free, вызов его несколько раз для одного указателя или разыменование указателя после того, как память, на которую он указывает, уже освобождена.

  • В C++ есть инструменты вроде умных указателей (unique_ptr, shared_ptr), которые используют гарантии языка о вызове деструкторов, чтобы память освобождалась при выходе из функции. Всё же этими инструментами легко злоупотребить и получить ошибки, похожие на C.

  • Java, Go и Python полагаются на сборщик мусора, который определяет память, до которой больше нельзя добраться, и освобождает её. Это гарантирует, что любой указатель можно разыменовать, что устраняет use-after-free и другие классы ошибок. Но у сборщика мусора есть стоимость во время выполнения, и его трудно правильно настроить.

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

Владение

У каждой привязки переменной есть область видимости, в которой она действительна, и использовать переменную вне её области видимости — ошибка:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct Point(i32, i32);

fn main() {
    {
        let p = Point(3, 4);
        dbg!(p.0);
    }
    dbg!(p.1);
}

Говорят, что переменная владеет значением. Каждое значение в Rust всегда имеет ровно одного владельца.

В конце области видимости переменная освобождается (dropped), и данные освобождаются. Здесь может выполниться деструктор, чтобы освободить ресурсы.

This slide should take about 5 minutes.

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

Перемещение

Присваивание передаёт владение между переменными:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let s1 = String::from("Hello!");
    let s2 = s1;
    dbg!(s2);
    // dbg!(s1);
}
  • Присваивание s1 в s2 передаёт владение.
  • Когда s1 выходит из области видимости, ничего не происходит: она ничем не владеет.
  • Когда s2 выходит из области видимости, данные строки освобождаются.

До перемещения в s2:

StackHeaps1ptrHello!len6capacity6

После перемещения в s2:

StackHeaps1ptrHello!len6capacity6s2ptrlen6capacity6(inaccessible)

Когда вы передаёте значение в функцию, оно присваивается параметру функции. Это передаёт владение:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn say_hello(name: String) {
    println!("{name}, привет!")
}

fn main() {
    let name = String::from("Алиса");
    say_hello(name);
    // say_hello(name);
}
This slide should take about 5 minutes.
  • Отметьте, что это противоположность поведению по умолчанию в C++, где значение копируется, если не использовать std::move (и определён конструктор перемещения!).

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

  • Простые значения (например, целые) можно пометить как Copy (см. следующие слайды).

  • В Rust клонирование явное (с помощью clone).

В примере say_hello:

  • При первом вызове say_hello main отдаёт владение name. После этого name в main больше нельзя использовать.
  • Память в куче, выделенная для name, будет освобождена в конце функции say_hello.
  • main может сохранить владение, если передаёт name как ссылку (&name) и если say_hello принимает ссылку в качестве параметра.
  • Либо main может передать клон name в первом вызове (name.clone()).
  • Rust усложняет случайное создание копий по сравнению с C++: семантика перемещения здесь по умолчанию, а программисты должны явно создавать клоны.

Для дальнейшего изучения

Защитные копии в современном C++

Современный C++ решает эту задачу иначе:

std::string s1 = "Cpp";
std::string s2 = s1;  // Дублируем данные из s1.
  • Данные в куче из s1 дублируются, и s2 получает собственную независимую копию.
  • Когда s1 и s2 выходят из области видимости, каждая из них освобождает свою память.

До копирующего присваивания:

StackHeaps1ptrCpplen3capacity3

После копирующего присваивания:

StackHeaps1ptrCpplen3capacity3s2ptrCpplen3capacity3

Ключевые моменты:

  • C++ сделал немного другой выбор, чем Rust. Поскольку = копирует данные, данные строки приходится клонировать. Иначе при выходе из области видимости любой из строк мы получили бы двойное освобождение.

  • В C++ также есть std::move, которым обозначают, что значение может быть перемещено. Если бы в примере было s2 = std::move(s1), выделения памяти в куче не произошло бы. После перемещения s1 оказалась бы в корректном, но не уточнённом состоянии. В отличие от Rust, программисту разрешено продолжать использовать s1.

  • В отличие от Rust, = в C++ может выполнять произвольный код, определённый типом, который копируется или перемещается.

Clone

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn say_hello(name: String) {
    println!("{name}, привет!")
}

fn main() {
    let name = String::from("Алиса");
    say_hello(name.clone());
    say_hello(name);
}
This slide should take about 2 minutes.
  • Идея Clone в том, чтобы было легко заметить места, где происходят выделения памяти в куче. Ищите .clone() и несколько других, например vec! или Box::new.

  • Обычное дело — «клонировать свой путь» через проблемы с анализатором заимствований, а потом возвращаться, чтобы попытаться оптимизировать эти клоны.

  • clone обычно выполняет глубокое копирование значения, то есть если, например, клонировать массив, клонируются и все его элементы.

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

Копируемые типы

Хотя семантика перемещения используется по умолчанию, некоторые типы копируются по умолчанию:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let x = 42;
    let y = x;
    dbg!(x); // would not be accessible if not Copy
    dbg!(y);
}

Эти типы реализуют трейт Copy.

Свои типы можно явно пометить для использования семантики копирования:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Copy, Clone, Debug)]
struct Point(i32, i32);

fn main() {
    let p1 = Point(3, 4);
    let p2 = p1;
    println!("p1: {p1:?}");
    println!("p2: {p2:?}");
}
  • После присваивания и p1, и p2 владеют собственными данными.
  • Также можно использовать p1.clone(), чтобы явно скопировать данные.
This slide should take about 5 minutes.

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

  • Копирование означает побитовое копирование областей памяти и не работает для произвольных объектов.
  • Копирование не позволяет выполнять произвольную логику (в отличие от конструкторов копирования в C++).
  • Клонирование — более общая операция, которая также позволяет задать собственное поведение, реализовав трейт Clone.
  • Копирование не работает для типов, которые реализуют трейт Drop.

В приведённом выше примере попробуйте следующее:

  • Добавьте поле String в struct Point. Код не скомпилируется, потому что String не является типом Copy.
  • Удалите Copy из атрибута derive. Ошибка компилятора теперь будет в println! для p1.
  • Покажите, что код работает, если вместо этого клонировать p1.

Для дальнейшего изучения

  • Разделяемые ссылки реализуют Copy/Clone, а изменяемые — нет. Это потому, что Rust требует, чтобы изменяемые ссылки были эксклюзивными: хотя копия разделяемой ссылки допустима, создание копии изменяемой ссылки нарушило бы правила заимствования Rust.

Трейт Drop

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct Droppable {
    name: &'static str,
}

impl Drop for Droppable {
    fn drop(&mut self) {
        println!("Dropping {}", self.name);
    }
}

fn main() {
    let a = Droppable { name: "a" };
    {
        let b = Droppable { name: "b" };
        {
            let c = Droppable { name: "c" };
            let d = Droppable { name: "d" };
            println!("Exiting innermost block");
        }
        println!("Exiting next block");
    }
    drop(a);
    println!("Exiting main");
}
This slide should take about 8 minutes.
  • Обратите внимание, что std::mem::drop — не то же самое, что std::ops::Drop::drop.
  • Значения автоматически освобождаются, когда выходят из области видимости.
  • Когда значение освобождается, если оно реализует std::ops::Drop, вызывается его реализация Drop::drop.
  • Затем освобождаются все его поля, независимо от того, реализует ли он Drop.
  • std::mem::drop — просто пустая функция, которая принимает любое значение. Важно то, что она забирает владение значением, поэтому в конце своей области видимости оно освобождается. Так удобно явно освободить значения раньше, чем они выйдут из области видимости.
    • Это полезно для объектов, которые выполняют работу при drop: освобождают блокировки, закрывают файлы и т. п.

Темы для обсуждения:

  • Почему Drop::drop не принимает self?
    • Коротко: если бы принимал, std::mem::drop вызывался бы в конце блока, что привело бы к повторному вызову Drop::drop и переполнению стека!
  • Попробуйте заменить drop(a) на a.drop().

Упражнение: Тип-построитель

В этом примере мы реализуем сложный тип данных, который владеет всеми своими данными. Мы используем «паттерн строитель» (builder pattern), чтобы поддержать постепенное построение нового значения с помощью удобных функций.

Заполните недостающие части.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug)]
enum Language {
    Rust,
    Java,
    Perl,
}

#[derive(Clone, Debug)]
struct Dependency {
    name: String,
    version_expression: String,
}

/// Представление программного пакета.
#[derive(Debug)]
struct Package {
    name: String,
    version: String,
    authors: Vec<String>,
    dependencies: Vec<Dependency>,
    language: Option<Language>,
}

impl Package {
    /// Возвращает представление этого пакета как зависимости, для использования при
    /// сборке других пакетов.
    fn to_dependency(&self) -> Dependency {
        todo!("1")
    }
}

/// Строитель для Package. Используйте `build()`, чтобы создать сам `Package`.
struct PackageBuilder(Package);

impl PackageBuilder {
    fn new(name: impl Into<String>) -> Self {
        todo!("2")
    }

    /// Задаёт версию пакета.
    fn version(mut self, version: impl Into<String>) -> Self {
        self.0.version = version.into();
        self
    }

    /// Задаёт авторов пакета.
    fn authors(mut self, authors: Vec<String>) -> Self {
        todo!("3")
    }

    /// Добавляет дополнительную зависимость.
    fn dependency(mut self, dependency: Dependency) -> Self {
        todo!("4")
    }

    /// Задаёт язык. Если не задан, язык по умолчанию --- None.
    fn language(mut self, language: Language) -> Self {
        todo!("5")
    }

    fn build(self) -> Package {
        self.0
    }
}

fn main() {
    let base64 = PackageBuilder::new("base64").version("0.13").build();
    dbg!(&base64);
    let log =
        PackageBuilder::new("log").version("0.4").language(Language::Rust).build();
    dbg!(&log);
    let serde = PackageBuilder::new("serde")
        .authors(vec!["djmitche".into()])
        .version(String::from("4.0"))
        .dependency(base64.to_dependency())
        .dependency(log.to_dependency())
        .build();
    dbg!(serde);
}

Решение

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug)]
enum Language {
    Rust,
    Java,
    Perl,
}

#[derive(Clone, Debug)]
struct Dependency {
    name: String,
    version_expression: String,
}

/// Представление программного пакета.
#[derive(Debug)]
struct Package {
    name: String,
    version: String,
    authors: Vec<String>,
    dependencies: Vec<Dependency>,
    language: Option<Language>,
}

impl Package {
    /// Возвращает представление этого пакета как зависимости, для использования при
    /// сборке других пакетов.
    fn to_dependency(&self) -> Dependency {
        Dependency {
            name: self.name.clone(),
            version_expression: self.version.clone(),
        }
    }
}

/// Строитель для Package. Используйте `build()`, чтобы создать сам `Package`.
struct PackageBuilder(Package);

impl PackageBuilder {
    fn new(name: impl Into<String>) -> Self {
        Self(Package {
            name: name.into(),
            version: "0.1".into(),
            authors: Vec::new(),
            dependencies: Vec::new(),
            language: None,
        })
    }

    /// Задаёт версию пакета.
    fn version(mut self, version: impl Into<String>) -> Self {
        self.0.version = version.into();
        self
    }

    /// Задаёт авторов пакета.
    fn authors(mut self, authors: Vec<String>) -> Self {
        self.0.authors = authors;
        self
    }

    /// Добавляет дополнительную зависимость.
    fn dependency(mut self, dependency: Dependency) -> Self {
        self.0.dependencies.push(dependency);
        self
    }

    /// Задаёт язык. Если не задан, язык по умолчанию --- None.
    fn language(mut self, language: Language) -> Self {
        self.0.language = Some(language);
        self
    }

    fn build(self) -> Package {
        self.0
    }
}

fn main() {
    let base64 = PackageBuilder::new("base64").version("0.13").build();
    dbg!(&base64);
    let log =
        PackageBuilder::new("log").version("0.4").language(Language::Rust).build();
    dbg!(&log);
    let serde = PackageBuilder::new("serde")
        .authors(vec!["djmitche".into()])
        .version(String::from("4.0"))
        .dependency(base64.to_dependency())
        .dependency(log.to_dependency())
        .build();
    dbg!(serde);
}

Умные указатели

Этот сегмент займёт около 55 минут. Он включает:

СлайдДлительность
Box10 минут
Rc5 минут
Владеющие объекты трейтов10 минут
Упражнение: Двоичное дерево30 минут

Box<T>

Box — владеющий указатель на данные в куче:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let five = Box::new(5);
    println!("five: {}", *five);
}
5StackHeapfive

Box<T> реализует Deref<Target = T>, что означает, что можно вызывать методы T напрямую на Box<T>.

Рекурсивные типы данных и типы данных с динамическим размером нельзя хранить напрямую без косвенности через указатель. Box обеспечивает эту косвенность:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug)]
enum List<T> {
    /// Непустой список: первый элемент и остальная часть списка.
    Element(T, Box<List<T>>),
    /// Пустой список.
    Nil,
}

fn main() {
    let list: List<i32> =
        List::Element(1, Box::new(List::Element(2, Box::new(List::Nil))));
    println!("{list:?}");
}
StackHeaplistElement1Element2Nil
This slide should take about 8 minutes.
  • Box похож на std::unique_ptr в C++, но гарантирует, что он не будет нулевым (null).

  • Box может быть полезен, когда вы:

    • имеете тип, размер которого нельзя узнать во время компиляции, но компилятору Rust нужен точный размер.
    • хотите передать владение большим объёмом данных. Чтобы не копировать большой объём данных на стеке, сохраните данные в куче в Box: тогда перемещается только указатель.
  • Если бы Box не использовался и мы попытались бы встроить List прямо в List, компилятор не смог бы вычислить фиксированный размер структуры в памяти (List был бы бесконечного размера).

  • Box решает эту проблему: он имеет тот же размер, что и обычный указатель, и просто указывает на следующий элемент List в куче.

  • Уберите Box из определения List и покажите ошибку компилятора. Мы получим сообщение «recursive without indirection», потому что для рекурсивных данных нужна косвенность — Box или ссылка какого-то вида, — а не прямое хранение значения.

  • Хотя Box выглядит как std::unique_ptr в C++, он не может быть пустым (null). Это делает Box одним из типов, которые позволяют компилятору оптимизировать хранение некоторых перечислений («оптимизация ниш»).

Rc

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::rc::Rc;

fn main() {
    let a = Rc::new(10);
    let b = Rc::clone(&a);

    dbg!(a);
    dbg!(b);
}

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

StackHeapcount:2value:10a:b:
  • См. Arc и Mutex, если вы работаете в многопоточном контексте.
  • Разделяемый указатель можно понизить до Weak указателя, чтобы создавать циклы, которые в итоге будут освобождены.
This slide should take about 5 minutes.
  • Счётчик Rc гарантирует, что содержащееся значение остаётся корректным, пока есть ссылки на него.
  • Rc в Rust похож на std::shared_ptr в C++.
  • Rc::clone дёшев: он создаёт указатель на ту же выделенную область памяти и увеличивает счётчик ссылок. Это не глубокое клонирование, и при поиске проблем с производительностью его обычно можно не учитывать.
  • make_mut при необходимости клонирует внутреннее значение («clone-on-write») и возвращает изменяемую ссылку.
  • Используйте Rc::strong_count, чтобы проверить счётчик ссылок.
  • Rc::downgrade даёт слабо подсчитываемый объект, чтобы создавать циклы, которые будут корректно освобождены (вероятно, в сочетании с RefCell).

Владеющие объекты трейтов

Ранее мы видели, как объекты трейтов можно использовать со ссылками, например &dyn Pet. Однако объекты трейтов можно использовать и с умными указателями, такими как Box, чтобы создать владеющий объект трейта: Box<dyn Pet>.

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct Dog {
    name: String,
    age: i8,
}
struct Cat {
    lives: i8,
}

trait Pet {
    fn talk(&self) -> String;
}

impl Pet for Dog {
    fn talk(&self) -> String {
        format!("Woof, my name is {}!", self.name)
    }
}

impl Pet for Cat {
    fn talk(&self) -> String {
        String::from("Miau!")
    }
}

fn main() {
    let pets: Vec<Box<dyn Pet>> = vec![
        Box::new(Cat { lives: 9 }),
        Box::new(Dog { name: String::from("Fido"), age: 5 }),
    ];
    for pet in pets {
        println!("Hello, who are you? {}", pet.talk());
    }
}

Раскладка памяти после выделения pets:

<Dog as Pet>::talk<Cat as Pet>::talkStackHeapFidoptrlives9len2capacity2data:name,4,4age5vtablevtablepets: Vec<Box<dyn Pet>>data: CatDogProgram text
This slide should take about 10 minutes.
  • Типы, которые реализуют данный трейт, могут иметь разные размеры. Поэтому, как в примере выше, невозможно иметь что-то вроде Vec<dyn Pet>.
  • dyn Pet — способ сообщить компилятору о типе динамического размера, который реализует Pet.
  • В примере pets выделен на стеке, а данные вектора — в куче. Два элемента вектора — толстые указатели (fat pointers):
    • Толстый указатель — это указатель двойной ширины. Он состоит из двух компонентов: указателя на сам объект и указателя на таблицу виртуальных методов (vtable) для реализации Pet этого конкретного объекта.
    • Данные для Dog по имени Fido — это поля name и age. У Cat есть поле lives.
  • Сравните эти выводы в примере выше:
    // Copyright 2024 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    println!("{} {}", std::mem::size_of::<Dog>(), std::mem::size_of::<Cat>());
    println!("{} {}", std::mem::size_of::<&Dog>(), std::mem::size_of::<&Cat>());
    println!("{}", std::mem::size_of::<&dyn Pet>());
    println!("{}", std::mem::size_of::<Box<dyn Pet>>());

Упражнение: Двоичное дерево

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

Реализуйте следующие типы так, чтобы заданные тесты проходили.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Узел двоичного дерева.
#[derive(Debug)]
struct Node<T: Ord> {
    value: T,
    left: Subtree<T>,
    right: Subtree<T>,
}

/// Поддерево, которое может быть пустым.
#[derive(Debug)]
struct Subtree<T: Ord>(Option<Box<Node<T>>>);

/// Контейнер, хранящий набор значений в двоичном дереве.
///
/// Если одно и то же значение добавляется несколько раз, оно хранится только один раз.
#[derive(Debug)]
pub struct BinaryTree<T: Ord> {
    root: Subtree<T>,
}

impl<T: Ord> BinaryTree<T> {
    fn new() -> Self {
        Self { root: Subtree::new() }
    }

    fn insert(&mut self, value: T) {
        self.root.insert(value);
    }

    fn has(&self, value: &T) -> bool {
        self.root.has(value)
    }

    fn len(&self) -> usize {
        self.root.len()
    }
}

// Реализуйте `new` для `Node`.
// Реализуйте `new`, `insert`, `len` и `has` для `Subtree`.

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn len() {
        let mut tree = BinaryTree::new();
        assert_eq!(tree.len(), 0);
        tree.insert(2);
        assert_eq!(tree.len(), 1);
        tree.insert(1);
        assert_eq!(tree.len(), 2);
        tree.insert(2); // не уникальный элемент
        assert_eq!(tree.len(), 2);
        tree.insert(3);
        assert_eq!(tree.len(), 3);
    }

    #[test]
    fn has() {
        let mut tree = BinaryTree::new();
        fn check_has(tree: &BinaryTree<i32>, exp: &[bool]) {
            let got: Vec<bool> =
                (0..exp.len()).map(|i| tree.has(&(i as i32))).collect();
            assert_eq!(&got, exp);
        }

        check_has(&tree, &[false, false, false, false, false]);
        tree.insert(0);
        check_has(&tree, &[true, false, false, false, false]);
        tree.insert(4);
        check_has(&tree, &[true, false, false, false, true]);
        tree.insert(4);
        check_has(&tree, &[true, false, false, false, true]);
        tree.insert(3);
        check_has(&tree, &[true, false, false, true, true]);
    }

    #[test]
    fn unbalanced() {
        let mut tree = BinaryTree::new();
        for i in 0..100 {
            tree.insert(i);
        }
        assert_eq!(tree.len(), 100);
        assert!(tree.has(&50));
    }
}

Решение

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::cmp::Ordering;

/// Узел двоичного дерева.
#[derive(Debug)]
struct Node<T: Ord> {
    value: T,
    left: Subtree<T>,
    right: Subtree<T>,
}

/// Поддерево, которое может быть пустым.
#[derive(Debug)]
struct Subtree<T: Ord>(Option<Box<Node<T>>>);

/// Контейнер, хранящий набор значений в двоичном дереве.
///
/// Если одно и то же значение добавляется несколько раз, оно хранится только один раз.
#[derive(Debug)]
pub struct BinaryTree<T: Ord> {
    root: Subtree<T>,
}

impl<T: Ord> BinaryTree<T> {
    fn new() -> Self {
        Self { root: Subtree::new() }
    }

    fn insert(&mut self, value: T) {
        self.root.insert(value);
    }

    fn has(&self, value: &T) -> bool {
        self.root.has(value)
    }

    fn len(&self) -> usize {
        self.root.len()
    }
}

impl<T: Ord> Subtree<T> {
    fn new() -> Self {
        Self(None)
    }

    fn insert(&mut self, value: T) {
        match &mut self.0 {
            None => self.0 = Some(Box::new(Node::new(value))),
            Some(n) => match value.cmp(&n.value) {
                Ordering::Less => n.left.insert(value),
                Ordering::Equal => {}
                Ordering::Greater => n.right.insert(value),
            },
        }
    }

    fn has(&self, value: &T) -> bool {
        match &self.0 {
            None => false,
            Some(n) => match value.cmp(&n.value) {
                Ordering::Less => n.left.has(value),
                Ordering::Equal => true,
                Ordering::Greater => n.right.has(value),
            },
        }
    }

    fn len(&self) -> usize {
        match &self.0 {
            None => 0,
            Some(n) => 1 + n.left.len() + n.right.len(),
        }
    }
}

impl<T: Ord> Node<T> {
    fn new(value: T) -> Self {
        Self { value, left: Subtree::new(), right: Subtree::new() }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn len() {
        let mut tree = BinaryTree::new();
        assert_eq!(tree.len(), 0);
        tree.insert(2);
        assert_eq!(tree.len(), 1);
        tree.insert(1);
        assert_eq!(tree.len(), 2);
        tree.insert(2); // не уникальный элемент
        assert_eq!(tree.len(), 2);
        tree.insert(3);
        assert_eq!(tree.len(), 3);
    }

    #[test]
    fn has() {
        let mut tree = BinaryTree::new();
        fn check_has(tree: &BinaryTree<i32>, exp: &[bool]) {
            let got: Vec<bool> =
                (0..exp.len()).map(|i| tree.has(&(i as i32))).collect();
            assert_eq!(&got, exp);
        }

        check_has(&tree, &[false, false, false, false, false]);
        tree.insert(0);
        check_has(&tree, &[true, false, false, false, false]);
        tree.insert(4);
        check_has(&tree, &[true, false, false, false, true]);
        tree.insert(4);
        check_has(&tree, &[true, false, false, false, true]);
        tree.insert(3);
        check_has(&tree, &[true, false, false, true, true]);
    }

    #[test]
    fn unbalanced() {
        let mut tree = BinaryTree::new();
        for i in 0..100 {
            tree.insert(i);
        }
        assert_eq!(tree.len(), 100);
        assert!(tree.has(&50));
    }
}

Добро пожаловать

Включая 10-минутные перерывы, это занятие займёт около 2 часов 30 минут. Оно включает:

СегментДлительность
Заимствование1 час 15 минут
Время жизни ссылок1 час 5 минут

Заимствование

Этот сегмент займёт около 1 часа 15 минут. Он включает:

СлайдДлительность
Заимствование значения10 минут
Анализатор заимствований10 минут
Ошибки заимствования3 минуты
Внутренняя изменяемость10 минут
Упражнение: инвентарь волшебника40 минут

Заимствование значения

Как мы видели раньше, вместо передачи владения при вызове функции можно позволить функции заимствовать значение:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug)]
struct Point(i32, i32);

fn add(p1: &Point, p2: &Point) -> Point {
    Point(p1.0 + p2.0, p1.1 + p2.1)
}

fn main() {
    let p1 = Point(3, 4);
    let p2 = Point(10, 20);
    let p3 = add(&p1, &p2);
    println!("{p1:?} + {p2:?} = {p3:?}");
}
  • Функция add заимствует две точки и возвращает новую точку.
  • Вызывающий код сохраняет владение входными данными.
This slide should take about 10 minutes.

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

Для дальнейшего изучения

Заметки о возвратах со стека и инлайнинге:

  • Покажите, что возврат из add дёшев, потому что компилятор может устранить операцию копирования, встроив вызов add в main. Измените приведённый код, чтобы он выводил адреса на стеке, и запустите его в Playground или посмотрите ассемблер в Godbolt. На уровне оптимизации «DEBUG» адреса должны меняться, а при «RELEASE» остаются одинаковыми:

    // Copyright 2023 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    #[derive(Debug)]
    struct Point(i32, i32);
    
    fn add(p1: &Point, p2: &Point) -> Point {
        let p = Point(p1.0 + p2.0, p1.1 + p2.1);
        println!("&p.0: {:p}", &p.0);
        p
    }
    
    pub fn main() {
        let p1 = Point(3, 4);
        let p2 = Point(10, 20);
        let p3 = add(&p1, &p2);
        println!("&p3.0: {:p}", &p3.0);
        println!("{p1:?} + {p2:?} = {p3:?}");
    }
  • Компилятор Rust умеет выполнять автоматический инлайнинг, который можно отключить для отдельной функции с помощью #[inline(never)].

  • После отключения напечатанный адрес будет меняться на всех уровнях оптимизации. Глядя в Godbolt или Playground, можно увидеть, что в этом случае возврат значения зависит от ABI: например, на amd64 два i32, из которых состоит точка, возвращаются в двух регистрах (eax и edx).

Анализатор заимствований

Анализатор заимствований (borrow checker) Rust накладывает ограничения на способы заимствования значений. Мы уже видели, что ссылка не может пережить значение, которое она заимствует:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let x_ref = {
        let x = 10;
        &x
    };
    dbg!(x_ref);
}

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

  • Можно иметь одну или несколько разделяемых ссылок на значение, или
  • Можно иметь ровно одну эксклюзивную ссылку на значение.
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let mut a = 10;
    let b = &a;

    {
        let c = &mut a;
        *c = 20;
    }

    dbg!(a);
    dbg!(b);
}
This slide should take about 10 minutes.
  • Правило «пережить» мы уже демонстрировали, когда впервые рассматривали ссылки. Здесь мы повторяем его, чтобы показать студентам, что анализатор заимствований следует нескольким разным правилам.
  • Приведённый выше код не компилируется, потому что a одновременно заимствуется как изменяемое (через c) и как неизменяемое (через b).
    • Обратите внимание, что требуется, чтобы конфликтующих ссылок не существовало в одной точке. Неважно, где происходит разыменование ссылки. Попробуйте закомментировать *c = 20 и покажите, что ошибка компилятора всё равно возникает, даже если мы никогда не используем c.
    • Обратите внимание, что промежуточная ссылка c не нужна, чтобы вызвать конфликт заимствований. Замените c прямым изменением a и покажите, что это даёт похожую ошибку. Так происходит, потому что прямое изменение значения фактически создаёт временную изменяемую ссылку.
  • Переместите оператор dbg! для b перед областью, которая вводит c, чтобы код скомпилировался.
    • После этого изменения компилятор понимает, что b используется только до нового изменяемого заимствования a через c. Это особенность анализатора заимствований, называемая «non-lexical lifetimes» (времена жизни, не ограниченные лексически).

Для дальнейшего изучения

  • Технически несколько изменяемых ссылок на один фрагмент данных могут существовать одновременно благодаря повторному заимствованию (re-borrowing). Именно это позволяет передать изменяемую ссылку в функцию, не делая исходную ссылку недействительной. Этот пример на playground демонстрирует такое поведение.
  • Rust использует ограничение на эксклюзивные ссылки, чтобы не допустить гонок данных в многопоточном коде: только один поток может иметь изменяемый доступ к данным в каждый момент времени.
  • Rust также использует это ограничение для оптимизации кода. Например, значение за разделяемой ссылкой можно безопасно кэшировать в регистре на время жизни этой ссылки.
  • Поля структуры можно заимствовать независимо друг от друга, но вызов метода структуры заимствует всю структуру, потенциально делая недействительными ссылки на отдельные поля. Пример см. в этом фрагменте на playground.

Ошибки заимствования

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

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let mut vec = vec![1, 2, 3, 4, 5];
    let elem = &vec[2];
    vec.push(6);
    dbg!(elem);
}

Аналогично рассмотрим инвалидацию итератора:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let mut vec = vec![1, 2, 3, 4, 5];
    for elem in &vec {
        vec.push(elem * 2);
    }
}
This slide should take about 3 minutes.
  • В обоих случаях изменение коллекции добавлением новых элементов может сделать недействительными существующие ссылки на элементы коллекции, если коллекции придётся перевыделить память.

Внутренняя изменяемость

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

Паттерн «внутренняя изменяемость» (interior mutability) позволяет эксклюзивный (изменяемый) доступ за разделяемой ссылкой. Стандартная библиотека предоставляет несколько способов это сделать, при этом сохраняя безопасность, обычно с помощью проверки во время выполнения.

This slide and its sub-slides should take about 10 minutes.

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

Cell

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::cell::Cell;

fn main() {
    // Обратите внимание, что `cell` НЕ объявлена как изменяемая.
    let cell = Cell::new(5);

    cell.set(123);
    dbg!(cell.get());
}
  • Cell — простое средство обеспечить безопасность: у него есть метод set, который принимает &self. Он не требует проверки во время выполнения, но требует перемещения значений, что может иметь собственную цену.

RefCell

RefCell позволяет обращаться к оборачиваемому значению и изменять его, предоставляя альтернативные типы Ref и RefMut, которые имитируют &T/&mut T, но при этом не являются ссылками Rust.

Эти типы выполняют динамические проверки с помощью счётчика в RefCell, чтобы не допустить существования RefMut одновременно с другим Ref/RefMut.

Реализуя Deref (а для RefMut — DerefMut), эти типы позволяют вызывать методы внутреннего значения, не допуская утечки ссылок наружу.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::cell::RefCell;

fn main() {
    // Обратите внимание, что `cell` НЕ объявлена как изменяемая.
    let cell = RefCell::new(5);

    {
        let mut cell_ref = cell.borrow_mut();
        *cell_ref = 123;

        // Это вызывает ошибку во время выполнения.
        // let other = cell.borrow();
        // println!("{}", other);
    }

    println!("{cell:?}");
}
  • RefCell обеспечивает обычные правила заимствования Rust (либо несколько разделяемых ссылок, либо одну эксклюзивную) с помощью проверки во время выполнения. В данном случае все заимствования очень короткие и никогда не пересекаются, поэтому проверки всегда проходят.

  • Дополнительный блок в примере нужен, чтобы завершить заимствование, созданное вызовом borrow_mut, до того как выводится ячейка. Попытка вывести заимствованный RefCell покажет только сообщение "{borrowed}".

Для дальнейшего изучения

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

Упражнение: инвентарь волшебника

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

  • У волшебника есть коллекция заклинаний. Нужно реализовать функции для добавления заклинаний в инвентарь и для их применения.

  • У заклинаний ограниченное число использований. Когда у заклинания не остаётся использований, его нужно удалить из инвентаря волшебника.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct Spell {
    name: String,
    cost: u32,
    uses: u32,
}

struct Wizard {
    spells: Vec<Spell>,
    mana: u32,
}

impl Wizard {
    fn new(mana: u32) -> Self {
        Wizard { spells: vec![], mana }
    }

    // TODO: Реализуйте `add_spell`, чтобы он забирал владение заклинанием и добавлял его в
    // инвентарь волшебника.
    fn add_spell(..., spell: ...) {
        todo!()
    }

    // TODO: Реализуйте `cast_spell`, чтобы он заимствовал заклинание из инвентаря и
    // применял его. Мана волшебника должна уменьшиться на стоимость заклинания, а
    // число использований заклинания --- на 1.
    //
    // Если у волшебника недостаточно маны, заклинание не срабатывает.
    // Если у заклинания не осталось использований, оно удаляется из инвентаря.
    fn cast_spell(..., name: ...) {
        todo!()
    }
}

fn main() {
    let mut merlin = Wizard::new(100);
    let fireball = Spell { name: String::from("Fireball"), cost: 10, uses: 2 };
    let ice_blast = Spell { name: String::from("Ice Blast"), cost: 15, uses: 1 };

    merlin.add_spell(fireball);
    merlin.add_spell(ice_blast);

    merlin.cast_spell("Fireball"); // Успешно применено
    merlin.cast_spell("Ice Blast"); // Успешно применено, затем удалено
    merlin.cast_spell("Ice Blast"); // Не срабатывает (не найдено)
    merlin.cast_spell("Fireball"); // Успешно применено, затем удалено
    merlin.cast_spell("Fireball"); // Не срабатывает (не найдено)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_add_spell() {
        let mut wizard = Wizard::new(10);
        let spell = Spell { name: String::from("Fireball"), cost: 5, uses: 3 };
        wizard.add_spell(spell);
        assert_eq!(wizard.spells.len(), 1);
    }

    #[test]
    fn test_cast_spell() {
        let mut wizard = Wizard::new(10);
        let spell = Spell { name: String::from("Fireball"), cost: 5, uses: 3 };
        wizard.add_spell(spell);

        wizard.cast_spell("Fireball");
        assert_eq!(wizard.mana, 5);
        assert_eq!(wizard.spells.len(), 1);
        assert_eq!(wizard.spells[0].uses, 2);
    }

    #[test]
    fn test_cast_spell_insufficient_mana() {
        let mut wizard = Wizard::new(10);
        let spell = Spell { name: String::from("Fireball"), cost: 15, uses: 3 };
        wizard.add_spell(spell);

        wizard.cast_spell("Fireball");
        assert_eq!(wizard.mana, 10);
        assert_eq!(wizard.spells.len(), 1);
        assert_eq!(wizard.spells[0].uses, 3);
    }

    #[test]
    fn test_cast_spell_not_found() {
        let mut wizard = Wizard::new(10);
        wizard.cast_spell("Fireball");
        assert_eq!(wizard.mana, 10);
    }

    #[test]
    fn test_cast_spell_removal() {
        let mut wizard = Wizard::new(10);
        let spell = Spell { name: String::from("Fireball"), cost: 5, uses: 1 };
        wizard.add_spell(spell);

        wizard.cast_spell("Fireball");
        assert_eq!(wizard.mana, 5);
        assert_eq!(wizard.spells.len(), 0);
    }
}
This slide and its sub-slides should take about 40 minutes.
  • Цель этого упражнения — отработать базовые концепции владения и заимствования, в частности правило, что нельзя изменять коллекцию, удерживая ссылку на один из её элементов.
  • add_spell должен забрать владение Spell и переместить его в инвентарь Wizard.
  • cast_spell — ядро упражнения. Ему нужно:
    1. Найти заклинание (по индексу или по ссылке).
    2. Проверить ману и уменьшить её.
    3. Уменьшить uses заклинания.
    4. Удалить заклинание, если uses == 0.
  • Конфликт заимствований: если студенты попытаются удерживать ссылку на заклинание (например, let spell = &mut self.spells[i]), а затем вызвать self.spells.remove(i), пока эта ссылка ещё «жива» в той же области видимости, анализатор заимствований выдаст ошибку. Это хороший повод показать, как структурировать код так, чтобы удовлетворить анализатор заимствований (например, используя индексы или обеспечив завершение заимствования до изменения).

Решение: инвентарь волшебника

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct Spell {
    name: String,
    cost: u32,
    uses: u32,
}

struct Wizard {
    spells: Vec<Spell>,
    mana: u32,
}

impl Wizard {
    fn new(mana: u32) -> Self {
        Wizard { spells: vec![], mana }
    }

    fn add_spell(&mut self, spell: Spell) {
        self.spells.push(spell);
    }

    fn cast_spell(&mut self, name: &str) {
        let mut spell_idx = None;
        for idx in 0..self.spells.len() {
            if self.spells[idx].name == name {
                spell_idx = Some(idx);
                break;
            }
        }

        let Some(idx) = spell_idx else {
            println!("Spell {} not found!", name);
            return;
        };

        let spell = &mut self.spells[idx];
        if self.mana >= spell.cost {
            self.mana -= spell.cost;
            spell.uses -= 1;
            println!(
                "Casting {}! Mana left: {}. Uses left: {}",
                spell.name, self.mana, spell.uses
            );
            if spell.uses == 0 {
                self.spells.remove(idx);
            }
        } else {
            println!("Not enough mana to cast {}!", spell.name);
        }
    }
}

fn main() {
    let mut merlin = Wizard::new(100);
    let fireball = Spell { name: String::from("Fireball"), cost: 10, uses: 2 };
    let ice_blast = Spell { name: String::from("Ice Blast"), cost: 15, uses: 1 };

    merlin.add_spell(fireball);
    merlin.add_spell(ice_blast);

    merlin.cast_spell("Fireball"); // Успешно применено
    merlin.cast_spell("Ice Blast"); // Успешно применено, затем удалено
    merlin.cast_spell("Ice Blast"); // Не срабатывает (не найдено)
    merlin.cast_spell("Fireball"); // Успешно применено, затем удалено
    merlin.cast_spell("Fireball"); // Не срабатывает (не найдено)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_add_spell() {
        let mut wizard = Wizard::new(10);
        let spell = Spell { name: String::from("Fireball"), cost: 5, uses: 3 };
        wizard.add_spell(spell);
        assert_eq!(wizard.spells.len(), 1);
    }

    #[test]
    fn test_cast_spell() {
        let mut wizard = Wizard::new(10);
        let spell = Spell { name: String::from("Fireball"), cost: 5, uses: 3 };
        wizard.add_spell(spell);

        wizard.cast_spell("Fireball");
        assert_eq!(wizard.mana, 5);
        assert_eq!(wizard.spells.len(), 1);
        assert_eq!(wizard.spells[0].uses, 2);
    }

    #[test]
    fn test_cast_spell_insufficient_mana() {
        let mut wizard = Wizard::new(10);
        let spell = Spell { name: String::from("Fireball"), cost: 15, uses: 3 };
        wizard.add_spell(spell);

        wizard.cast_spell("Fireball");
        assert_eq!(wizard.mana, 10);
        assert_eq!(wizard.spells.len(), 1);
        assert_eq!(wizard.spells[0].uses, 3);
    }

    #[test]
    fn test_cast_spell_not_found() {
        let mut wizard = Wizard::new(10);
        wizard.cast_spell("Fireball");
        assert_eq!(wizard.mana, 10);
    }

    #[test]
    fn test_cast_spell_removal() {
        let mut wizard = Wizard::new(10);
        let spell = Spell { name: String::from("Fireball"), cost: 5, uses: 1 };
        wizard.add_spell(spell);

        wizard.cast_spell("Fireball");
        assert_eq!(wizard.mana, 5);
        assert_eq!(wizard.spells.len(), 0);
    }
}

Время жизни ссылок

Этот сегмент займёт около 1 часа 5 минут. Он включает:

СлайдДлительность
Заимствование и функции3 минуты
Возврат заимствований5 минут
Несколько заимствований5 минут
Заимствование обоих5 минут
Заимствование одного5 минут
Неявное выведение времени жизни5 минут
Времена жизни в структурах данных5 минут
Упражнение: Разбор Protobuf30 минут

Заимствование в функциях

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn borrows(x: &i32) {
    dbg!(x);
}

fn main() {
    let mut val = 123;

    // Заимствуем `val` на время вызова функции.
    borrows(&val);

    // Заимствование закончилось, и можно изменять значение.
    val += 5;
}
This slide should take about 3 minutes.
  • В этом примере мы заимствуем val для вызова borrows. Это ограничивает нашу возможность изменять val, но как только вызов функции возвращается, заимствование заканчивается и мы снова свободны изменять значение.

Возврат заимствований

Но функция также может возвращать ссылку! Это означает, что заимствование передаётся обратно из функции:

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn identity(x: &i32) -> &i32 {
    x
}

fn main() {
    let mut x = 123;

    let out = identity(&x);

    // x = 5; // 🛠️❌ `x` всё ещё заимствован!

    dbg!(out);
}
This slide should take about 5 minutes.
  • Функции Rust могут возвращать ссылки, то есть заимствование может передаваться обратно из функции.

  • Если функция возвращает ссылку (или другой вид заимствования), она, скорее всего, получена из одного из аргументов. Это значит, что возвращаемое значение функции продлит заимствование одного или нескольких аргументов.

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

Несколько заимствований

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn multiple(a: &i32, b: &i32) -> &i32 {
    todo!("Return either `a` or `b`")
}

fn main() {
    let mut a = 5;
    let mut b = 10;

    let r = multiple(&a, &b);

    // Какой из них всё ещё заимствован?
    // Можно ли разрешить какое-либо из изменений?
    a += 7;
    b += 7;

    dbg!(r);
}
This slide should take about 5 minutes.
  • Этот код сейчас не компилируется, потому что в нём нет аннотаций времён жизни. Прежде чем заставить его компилироваться, используйте эту возможность, чтобы студенты подумали о том, какое из заимствований аргументов должно быть продлено возвращаемым значением.

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

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

  • В этом случае недостаточно информации, чтобы определить, будет ли возвращённая ссылка заимствовать a или b. Покажите студентам ошибки компилятора и введите синтаксис времён жизни:

    // Copyright 2025 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    fn multiple<'a>(a: &'a i32, b: &'a i32) -> &'a i32 { ... }

Заимствование обоих

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn pick<'a>(c: bool, a: &'a i32, b: &'a i32) -> &'a i32 {
    if c { a } else { b }
}

fn main() {
    let mut a = 5;
    let mut b = 10;

    let r = pick(true, &a, &b);

    // Какой из них всё ещё заимствован?
    // Можно ли разрешить какое-либо из изменений?
    // a += 7;
    // b += 7;

    dbg!(r);
}
This slide should take about 5 minutes.
  • Функция pick вернёт либо a, либо b в зависимости от значения c, поэтому во время компиляции мы не можем знать, какое из них будет возвращено.

  • Чтобы выразить это компилятору, мы используем одно и то же время жизни для a и b, а также для возвращаемого типа. Это значит, что возвращённая ссылка заимствует И a, И b!

  • Раскомментируйте обе закомментированные строки и покажите, что r заимствует и a, и b, даже если во время выполнения она будет указывать только на одну из них.

  • Измените первый аргумент pick, чтобы показать, что результат одинаков независимо от того, возвращается ли a или b.

Заимствование одного

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug)]
struct Point(i32, i32);

/// Ищет в `points` точку, ближайшую к `query`.
/// Предполагается, что в `points` есть хотя бы одна точка.
fn find_nearest<'a>(points: &'a [Point], query: &Point) -> &'a Point {
    fn cab_distance(p1: &Point, p2: &Point) -> i32 {
        (p1.0 - p2.0).abs() + (p1.1 - p2.1).abs()
    }

    let mut nearest = None;
    for p in points {
        if let Some((_, nearest_dist)) = nearest {
            let dist = cab_distance(p, query);
            if dist < nearest_dist {
                nearest = Some((p, dist));
            }
        } else {
            nearest = Some((p, cab_distance(p, query)));
        };
    }

    nearest.expect("at least one point expected").0
    // query // Что будет, если сделать вот так?
}

fn main() {
    let points = &[Point(1, 0), Point(1, 0), Point(-1, 0), Point(0, -1)];
    let query = Point(0, 2);
    let nearest = find_nearest(points, &query);

    // `query` в этот момент не заимствуется.
    drop(query);

    dbg!(nearest);
}
This slide should take about 5 minutes.
  • Может помочь свернуть определение find_nearest, чтобы сделать акцент на сигнатуре функции. Реальная логика функции довольно сложна и для анализа заимствований не важна.

  • Когда мы вызываем find_nearest, возвращённая ссылка не заимствует query, поэтому мы свободны освободить его, пока nearest ещё активна.

  • Но что будет, если вернуть неправильное заимствование? Измените последнюю строку find_nearest, чтобы она возвращала query. Покажите студентам ошибку компилятора.

  • Первое, что нужно сделать, — добавить аннотацию времени жизни к query. Покажите студентам, что мы можем добавить второе время жизни 'b к find_nearest.

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

Для дальнейшего изучения

  • В сообщении «help» сказано, что мы можем добавить ограничение времени жизни 'b: 'a, чтобы указать, что 'b будет жить не меньше, чем 'a, что позволит вернуть query. Это пример подтипизации времён жизни (lifetime subtyping), которая позволяет вернуть более долгое время жизни там, где ожидается более короткое.

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

Неявное выведение времени жизни

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

  • Каждому аргументу, у которого нет аннотации времени жизни, присваивается отдельное время жизни.
  • Если есть только одно время жизни аргумента, оно присваивается всем неаннотированным заимствованиям в возвращаемом типе.
  • Если аргументов с временами жизни несколько, но первый из них — self, то его время жизни присваивается всем неаннотированным заимствованиям в возвращаемом типе.
// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn only_args(a: &i32, b: &i32) {
    todo!();
}

fn identity(a: &i32) -> &i32 {
    a
}

struct Foo(i32);
impl Foo {
    fn get(&self, other: &i32) -> &i32 {
        &self.0
    }
}
This slide should take about 5 minutes.
  • Пройдите применение правил опускания времён жизни к каждой из функций-примеров. only_args дополняется первым правилом, identity — вторым, а Foo::get — третьим.

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

Времена жизни в структурах данных

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

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug)]
enum HighlightColor {
    Pink,
    Yellow,
}

#[derive(Debug)]
struct Highlight<'document> {
    slice: &'document str,
    color: HighlightColor,
}

fn main() {
    let doc = String::from("The quick brown fox jumps over the lazy dog.");
    let noun = Highlight { slice: &doc[16..19], color: HighlightColor::Yellow };
    let verb = Highlight { slice: &doc[20..25], color: HighlightColor::Pink };
    // drop(doc);
    dbg!(noun);
    dbg!(verb);
}
This slide should take about 5 minutes.
  • В примере выше аннотация у Highlight требует, чтобы данные, лежащие в основе содержащейся &str, жили не меньше, чем любой экземпляр Highlight, использующий эти данные. Структура не может жить дольше данных, на которые она ссылается.
  • Если doc будет освобождён раньше окончания времени жизни noun или verb, анализатор заимствований выдаст ошибку.
  • Типы с заимствованными данными заставляют пользователей сохранять исходные данные. Это может быть полезно для создания лёгких представлений, но обычно делает их несколько сложнее в использовании.
  • Когда возможно, пусть структуры данных владеют своими данными напрямую.
  • Некоторые структуры с несколькими ссылками внутри могут иметь больше одной аннотации времени жизни. Это может понадобиться, если нужно описать отношения времён жизни между самими ссылками, помимо времени жизни самой структуры. Это очень продвинутые сценарии.

Упражнение: Разбор Protobuf

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

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

Мы будем использовать следующий proto:

message PhoneNumber {
  optional string number = 1;
  optional string type = 2;
}

message Person {
  optional string name = 1;
  optional int32 id = 2;
  repeated PhoneNumber phones = 3;
}

Сообщения

Сообщение proto кодируется как последовательность полей, одно за другим. Каждое поле реализовано как «тег», за которым следует значение. Тег содержит номер поля (например, 2 для поля id сообщения Person) и тип передачи (wire type), определяющий, как по потоку байтов находить полезную нагрузку. Они объединяются в одно целое число, которое декодируется в unpack_tag ниже.

Varint

Целые числа, включая тег, представлены с помощью кодирования переменной длины, которое называется VARINT. К счастью, parse_varint уже определена ниже.

Типы передачи (Wire Types)

Proto определяет несколько типов передачи, из которых в этом упражнении используются только два.

Тип передачи Varint содержит одно varint и используется для кодирования значений proto типа int32, например Person.id.

Тип передачи Len содержит длину, выраженную как varint, за которой следует полезная нагрузка из стольких байтов. Он используется для кодирования значений proto типа string, например Person.name. Также он используется для значений proto, содержащих вложенные сообщения, например Person.phones, где полезная нагрузка содержит кодирование вложенного сообщения.

Упражнение

Приведённый код также определяет обратные вызовы для обработки полей Person и PhoneNumber, а также для разбора сообщения на последовательность вызовов этих обратных вызовов.

От вас остаётся реализовать функцию parse_field и трейт ProtoMessage для Person и PhoneNumber.

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Тип передачи в том виде, в котором он появляется в потоке.
enum WireType {
    /// Тип передачи Varint указывает, что значение --- одно VARINT.
    Varint,
    // Тип передачи I64 указывает, что значение --- ровно 8 байт в порядке
    // little-endian, содержащие 64-битное знаковое целое или значение типа double.
    //I64,  -- не нужно для этого упражнения
    /// Тип передачи Len указывает, что значение --- длина, представленная как
    /// VARINT, за которой следует ровно столько байтов.
    Len,
    // Тип передачи I32 указывает, что значение --- ровно 4 байта в порядке
    // little-endian, содержащие 32-битное знаковое целое или значение типа float.
    //I32,  -- не нужно для этого упражнения
}

#[derive(Debug)]
/// Значение поля, типизированное на основе типа передачи.
enum FieldValue<'a> {
    Varint(u64),
    //I64(i64),  -- не нужно для этого упражнения
    Len(&'a [u8]),
    //I32(i32),  -- не нужно для этого упражнения
}

#[derive(Debug)]
/// Поле, содержащее номер поля и его значение.
struct Field<'a> {
    field_num: u64,
    value: FieldValue<'a>,
}

trait ProtoMessage<'a>: Default {
    fn add_field(&mut self, field: Field<'a>);
}

impl From<u64> for WireType {
    fn from(value: u64) -> Self {
        match value {
            0 => WireType::Varint,
            //1 => WireType::I64,  -- не нужно для этого упражнения
            2 => WireType::Len,
            //5 => WireType::I32,  -- не нужно для этого упражнения
            _ => panic!("Invalid wire type: {value}"),
        }
    }
}

impl<'a> FieldValue<'a> {
    fn as_str(&self) -> &'a str {
        let FieldValue::Len(data) = self else {
            panic!("Expected string to be a `Len` field");
        };
        std::str::from_utf8(data).expect("Invalid string")
    }

    fn as_bytes(&self) -> &'a [u8] {
        let FieldValue::Len(data) = self else {
            panic!("Expected bytes to be a `Len` field");
        };
        data
    }

    fn as_u64(&self) -> u64 {
        let FieldValue::Varint(value) = self else {
            panic!("Expected `u64` to be a `Varint` field");
        };
        *value
    }
}

/// Разбирает VARINT, возвращая разобранное значение и оставшиеся байты.
fn parse_varint(data: &[u8]) -> (u64, &[u8]) {
    for i in 0..7 {
        let Some(b) = data.get(i) else {
            panic!("Not enough bytes for varint");
        };
        if b & 0x80 == 0 {
            // Это последний байт VARINT, поэтому преобразуем его в
            // u64 и вернём.
            let mut value = 0u64;
            for b in data[..=i].iter().rev() {
                value = (value << 7) | (b & 0x7f) as u64;
            }
            return (value, &data[i + 1..]);
        }
    }

    // Более 7 байтов недопустимо.
    panic!("Too many bytes for varint");
}

/// Преобразует тег в номер поля и WireType.
fn unpack_tag(tag: u64) -> (u64, WireType) {
    let field_num = tag >> 3;
    let wire_type = WireType::from(tag & 0x7);
    (field_num, wire_type)
}


/// Разбирает поле, возвращая оставшиеся байты
fn parse_field(data: &[u8]) -> (Field<'_>, &[u8]) {
    let (tag, remainder) = parse_varint(data);
    let (field_num, wire_type) = unpack_tag(tag);
    let (fieldvalue, remainder) = match wire_type {
        _ => todo!("Based on the wire type, build a Field, consuming as many bytes as necessary.")
    };
    todo!("Return the field, and any un-consumed bytes.")
}

/// Разбирает сообщение в заданных данных, вызывая `T::add_field` для каждого поля в
/// сообщении.
///
/// Весь входной набор данных потребляется.
fn parse_message<'a, T: ProtoMessage<'a>>(mut data: &'a [u8]) -> T {
    let mut result = T::default();
    while !data.is_empty() {
        let parsed = parse_field(data);
        result.add_field(parsed.0);
        data = parsed.1;
    }
    result
}

#[derive(Debug, Default)]
struct PhoneNumber<'a> {
    number: &'a str,
    type_: &'a str,
}

#[derive(Debug, Default)]
struct Person<'a> {
    name: &'a str,
    id: u64,
    phone: Vec<PhoneNumber<'a>>,
}

// TODO: Реализуйте ProtoMessage для Person и PhoneNumber.

#[test]
fn test_id() {
    let person_id: Person = parse_message(&[0x10, 0x2a]);
    assert_eq!(person_id, Person { name: "", id: 42, phone: vec![] });
}

#[test]
fn test_name() {
    let person_name: Person = parse_message(&[
        0x0a, 0x0e, 0x62, 0x65, 0x61, 0x75, 0x74, 0x69, 0x66, 0x75, 0x6c, 0x20,
        0x6e, 0x61, 0x6d, 0x65,
    ]);
    assert_eq!(person_name, Person { name: "beautiful name", id: 0, phone: vec![] });
}

#[test]
fn test_just_person() {
    let person_name_id: Person =
        parse_message(&[0x0a, 0x04, 0x45, 0x76, 0x61, 0x6e, 0x10, 0x16]);
    assert_eq!(person_name_id, Person { name: "Evan", id: 22, phone: vec![] });
}

#[test]
fn test_phone() {
    let phone: Person = parse_message(&[
        0x0a, 0x00, 0x10, 0x00, 0x1a, 0x16, 0x0a, 0x0e, 0x2b, 0x31, 0x32, 0x33,
        0x34, 0x2d, 0x37, 0x37, 0x37, 0x2d, 0x39, 0x30, 0x39, 0x30, 0x12, 0x04,
        0x68, 0x6f, 0x6d, 0x65,
    ]);
    assert_eq!(
        phone,
        Person {
            name: "",
            id: 0,
            phone: vec![PhoneNumber { number: "+1234-777-9090", type_: "home" },],
        }
    );
}

// Соберите всё это в один разбор.
#[test]
fn test_full_person() {
    let person: Person = parse_message(&[
        0x0a, 0x07, 0x6d, 0x61, 0x78, 0x77, 0x65, 0x6c, 0x6c, 0x10, 0x2a, 0x1a,
        0x16, 0x0a, 0x0e, 0x2b, 0x31, 0x32, 0x30, 0x32, 0x2d, 0x35, 0x35, 0x35,
        0x2d, 0x31, 0x32, 0x31, 0x32, 0x12, 0x04, 0x68, 0x6f, 0x6d, 0x65, 0x1a,
        0x18, 0x0a, 0x0e, 0x2b, 0x31, 0x38, 0x30, 0x30, 0x2d, 0x38, 0x36, 0x37,
        0x2d, 0x35, 0x33, 0x30, 0x38, 0x12, 0x06, 0x6d, 0x6f, 0x62, 0x69, 0x6c,
        0x65,
    ]);
    assert_eq!(
        person,
        Person {
            name: "maxwell",
            id: 42,
            phone: vec![
                PhoneNumber { number: "+1202-555-1212", type_: "home" },
                PhoneNumber { number: "+1800-867-5308", type_: "mobile" },
            ]
        }
    );
}
This slide and its sub-slides should take about 30 minutes.
  • В этом упражнении есть разные случаи, когда разбор protobuf может завершиться ошибкой, например если попытаться разобрать i32, когда в буфере данных осталось меньше 4 байтов. В обычном коде на Rust мы обработали бы это с помощью перечисления Result, но для простоты в этом упражнении мы вызываем панику при любой ошибке. В четвёртый день мы подробнее рассмотрим обработку ошибок в Rust.

Решение

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Тип передачи в том виде, в котором он появляется в потоке.
enum WireType {
    /// Тип передачи Varint указывает, что значение --- одно VARINT.
    Varint,
    // Тип передачи I64 указывает, что значение --- ровно 8 байт в порядке
    // little-endian, содержащие 64-битное знаковое целое или значение типа double.
    //I64,  -- не нужно для этого упражнения
    /// Тип передачи Len указывает, что значение --- длина, представленная как
    /// VARINT, за которой следует ровно столько байтов.
    Len,
    // Тип передачи I32 указывает, что значение --- ровно 4 байта в порядке
    // little-endian, содержащие 32-битное знаковое целое или значение типа float.
    //I32,  -- не нужно для этого упражнения
}

#[derive(Debug)]
/// Значение поля, типизированное на основе типа передачи.
enum FieldValue<'a> {
    Varint(u64),
    //I64(i64),  -- не нужно для этого упражнения
    Len(&'a [u8]),
    //I32(i32),  -- не нужно для этого упражнения
}

#[derive(Debug)]
/// Поле, содержащее номер поля и его значение.
struct Field<'a> {
    field_num: u64,
    value: FieldValue<'a>,
}

trait ProtoMessage<'a>: Default {
    fn add_field(&mut self, field: Field<'a>);
}

impl From<u64> for WireType {
    fn from(value: u64) -> Self {
        match value {
            0 => WireType::Varint,
            //1 => WireType::I64,  -- не нужно для этого упражнения
            2 => WireType::Len,
            //5 => WireType::I32,  -- не нужно для этого упражнения
            _ => panic!("Invalid wire type: {value}"),
        }
    }
}

impl<'a> FieldValue<'a> {
    fn as_str(&self) -> &'a str {
        let FieldValue::Len(data) = self else {
            panic!("Expected string to be a `Len` field");
        };
        std::str::from_utf8(data).expect("Invalid string")
    }

    fn as_bytes(&self) -> &'a [u8] {
        let FieldValue::Len(data) = self else {
            panic!("Expected bytes to be a `Len` field");
        };
        data
    }

    fn as_u64(&self) -> u64 {
        let FieldValue::Varint(value) = self else {
            panic!("Expected `u64` to be a `Varint` field");
        };
        *value
    }
}

/// Разбирает VARINT, возвращая разобранное значение и оставшиеся байты.
fn parse_varint(data: &[u8]) -> (u64, &[u8]) {
    for i in 0..7 {
        let Some(b) = data.get(i) else {
            panic!("Not enough bytes for varint");
        };
        if b & 0x80 == 0 {
            // Это последний байт VARINT, поэтому преобразуем его в
            // u64 и вернём.
            let mut value = 0u64;
            for b in data[..=i].iter().rev() {
                value = (value << 7) | (b & 0x7f) as u64;
            }
            return (value, &data[i + 1..]);
        }
    }

    // Более 7 байтов недопустимо.
    panic!("Too many bytes for varint");
}

/// Преобразует тег в номер поля и WireType.
fn unpack_tag(tag: u64) -> (u64, WireType) {
    let field_num = tag >> 3;
    let wire_type = WireType::from(tag & 0x7);
    (field_num, wire_type)
}

/// Разбирает поле, возвращая оставшиеся байты
fn parse_field(data: &[u8]) -> (Field<'_>, &[u8]) {
    let (tag, remainder) = parse_varint(data);
    let (field_num, wire_type) = unpack_tag(tag);
    let (fieldvalue, remainder) = match wire_type {
        WireType::Varint => {
            let (value, remainder) = parse_varint(remainder);
            (FieldValue::Varint(value), remainder)
        }
        WireType::Len => {
            let (len, remainder) = parse_varint(remainder);
            let len = len as usize; // приведение для простоты
            let (value, remainder) = remainder.split_at(len);
            (FieldValue::Len(value), remainder)
        }
    };
    (Field { field_num, value: fieldvalue }, remainder)
}

/// Разбирает сообщение в заданных данных, вызывая `T::add_field` для каждого поля в
/// сообщении.
///
/// Весь входной набор данных потребляется.
fn parse_message<'a, T: ProtoMessage<'a>>(mut data: &'a [u8]) -> T {
    let mut result = T::default();
    while !data.is_empty() {
        let parsed = parse_field(data);
        result.add_field(parsed.0);
        data = parsed.1;
    }
    result
}

#[derive(PartialEq)]
#[derive(Debug, Default)]
struct PhoneNumber<'a> {
    number: &'a str,
    type_: &'a str,
}

#[derive(PartialEq)]
#[derive(Debug, Default)]
struct Person<'a> {
    name: &'a str,
    id: u64,
    phone: Vec<PhoneNumber<'a>>,
}

impl<'a> ProtoMessage<'a> for Person<'a> {
    fn add_field(&mut self, field: Field<'a>) {
        match field.field_num {
            1 => self.name = field.value.as_str(),
            2 => self.id = field.value.as_u64(),
            3 => self.phone.push(parse_message(field.value.as_bytes())),
            _ => {} // пропускаем всё остальное
        }
    }
}

impl<'a> ProtoMessage<'a> for PhoneNumber<'a> {
    fn add_field(&mut self, field: Field<'a>) {
        match field.field_num {
            1 => self.number = field.value.as_str(),
            2 => self.type_ = field.value.as_str(),
            _ => {} // пропускаем всё остальное
        }
    }
}

#[test]
fn test_id() {
    let person_id: Person = parse_message(&[0x10, 0x2a]);
    assert_eq!(person_id, Person { name: "", id: 42, phone: vec![] });
}

#[test]
fn test_name() {
    let person_name: Person = parse_message(&[
        0x0a, 0x0e, 0x62, 0x65, 0x61, 0x75, 0x74, 0x69, 0x66, 0x75, 0x6c, 0x20,
        0x6e, 0x61, 0x6d, 0x65,
    ]);
    assert_eq!(person_name, Person { name: "beautiful name", id: 0, phone: vec![] });
}

#[test]
fn test_just_person() {
    let person_name_id: Person =
        parse_message(&[0x0a, 0x04, 0x45, 0x76, 0x61, 0x6e, 0x10, 0x16]);
    assert_eq!(person_name_id, Person { name: "Evan", id: 22, phone: vec![] });
}

#[test]
fn test_phone() {
    let phone: Person = parse_message(&[
        0x0a, 0x00, 0x10, 0x00, 0x1a, 0x16, 0x0a, 0x0e, 0x2b, 0x31, 0x32, 0x33,
        0x34, 0x2d, 0x37, 0x37, 0x37, 0x2d, 0x39, 0x30, 0x39, 0x30, 0x12, 0x04,
        0x68, 0x6f, 0x6d, 0x65,
    ]);
    assert_eq!(
        phone,
        Person {
            name: "",
            id: 0,
            phone: vec![PhoneNumber { number: "+1234-777-9090", type_: "home" },],
        }
    );
}

// Соберите всё это в один разбор.
#[test]
fn test_full_person() {
    let person: Person = parse_message(&[
        0x0a, 0x07, 0x6d, 0x61, 0x78, 0x77, 0x65, 0x6c, 0x6c, 0x10, 0x2a, 0x1a,
        0x16, 0x0a, 0x0e, 0x2b, 0x31, 0x32, 0x30, 0x32, 0x2d, 0x35, 0x35, 0x35,
        0x2d, 0x31, 0x32, 0x31, 0x32, 0x12, 0x04, 0x68, 0x6f, 0x6d, 0x65, 0x1a,
        0x18, 0x0a, 0x0e, 0x2b, 0x31, 0x38, 0x30, 0x30, 0x2d, 0x38, 0x36, 0x37,
        0x2d, 0x35, 0x33, 0x30, 0x38, 0x12, 0x06, 0x6d, 0x6f, 0x62, 0x69, 0x6c,
        0x65,
    ]);
    assert_eq!(
        person,
        Person {
            name: "maxwell",
            id: 42,
            phone: vec![
                PhoneNumber { number: "+1202-555-1212", type_: "home" },
                PhoneNumber { number: "+1800-867-5308", type_: "mobile" },
            ]
        }
    );
}

Добро пожаловать в День 4

Мы освоили базовый язык и его уникальную модель безопасности:

  • Основы и абстракции: трейты, обобщения и стандартная библиотека.
  • Владение: семантика перемещения и трейт Drop.
  • Управление памятью: правила заимствования (& и &mut) и времена жизни.
  • Умные указатели: Box, Rc и RefCell для сложных структур данных.

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

Расписание

Включая 10-минутные перерывы, это занятие займёт около 2 часов 50 минут. Оно включает:

СегментДлительность
Добро пожаловать3 минуты
Итераторы55 минут
Модули45 минут
Тестирование45 минут

Итераторы

Этот сегмент займёт около 55 минут. Он включает:

СлайдДлительность
Мотивация3 минуты
Трейт Iterator5 минут
Вспомогательные методы Iterator5 минут
collect5 минут
Трейт IntoIterator5 минут
Упражнение: Цепочка вычислений30 минут

Повторение: for

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

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    // Принимает диапазон.
    for x in 1..5 {
        dbg!(x);
    }

    // Также принимает массив.
    for elem in [2, 4, 8, 16, 32] {
        dbg!(elem);
    }
}

Зачем нужны итераторы

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

  • Некоторое состояние, отслеживающее текущую позицию в процессе итерации, например индекс.
  • Условие, определяющее, когда итерация завершена.
  • Логику обновления состояния итерации на каждом шаге цикла.
  • Логику получения каждого элемента с использованием этого состояния итерации.

В цикле for в стиле C вы объявляете эти вещи напрямую:

for (int i = 0; i < array_len; i += 1) {
    int elem = array[i];
}

В Rust мы объединяем это состояние и логику в объект, известный как «итератор».

This slide should take about 3 minutes.
  • Этот слайд даёт контекст того, что итераторы Rust делают под капотом. Мы используем знакомую конструкцию цикла for в стиле C, чтобы показать, что итерация требует некоторого состояния и некоторой логики; на следующем слайде мы покажем, как итератор объединяет их.

  • В Rust нет цикла for в стиле C, но то же самое можно выразить через while:

    // Copyright 2024 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    let array = [2, 4, 6, 8];
    let mut i = 0;
    while i < array.len() {
        let elem = array[i];
        i += 1;
    }

Для дальнейшего изучения

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

for (int *ptr = array; ptr < array + len; ptr += 1) {
    int elem = *ptr;
}

Если студенты спросят, можно указать, что именно так под капотом работают итераторы срезов и массивов в Rust (хотя реализованы они как итераторы Rust).

Трейт Iterator

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct SliceIter<'s> {
    slice: &'s [i32],
    i: usize,
}

impl<'s> Iterator for SliceIter<'s> {
    type Item = &'s i32;

    fn next(&mut self) -> Option<Self::Item> {
        if self.i == self.slice.len() {
            None
        } else {
            let next = &self.slice[self.i];
            self.i += 1;
            Some(next)
        }
    }
}

fn main() {
    let slice = &[2, 4, 6, 8];
    let iter = SliceIter { slice, i: 0 };
    for elem in iter {
        dbg!(elem);
    }
}
This slide should take about 5 minutes.
  • Пример SliceIter реализует ту же логику, что и цикл for в стиле C из предыдущего слайда.

  • Укажите студентам, что итераторы ленивы: создание итератора лишь инициализирует структуру, а никакой работы не выполняет. Работа начинается только при вызове метода next.

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

Для дальнейшего изучения

  • «Настоящая» версия SliceIter — это тип slice::Iter из стандартной библиотеки, но настоящая версия использует под капотом указатели вместо индекса, чтобы исключить проверки границ.

  • Пример SliceIter хорошо показывает структуру, которая содержит ссылку, и поэтому использует аннотации времён жизни.

  • Также можно показать, как добавить обобщённый параметр в SliceIter, чтобы он работал с любым срезом (а не только с &[i32]).

Вспомогательные методы Iterator

Помимо метода next, который определяет поведение итератора, трейт Iterator предоставляет более 70 вспомогательных методов, с помощью которых можно строить настраиваемые итераторы.

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let result: i32 = (1..=10) // Создаём диапазон от 1 до 10
        .filter(|x| x % 2 == 0) // Оставляем только чётные числа
        .map(|x| x * x) // Возводим каждое число в квадрат
        .sum(); // Суммируем все квадраты

    println!("The sum of squares of even numbers from 1 to 10 is: {}", result);
}
This slide should take about 5 minutes.
  • Трейт Iterator реализует многие распространённые операции функционального программирования над коллекциями (например, map, filter, reduce и т. д.). Все их описания можно найти в документации к этому трейту.

  • Многие из этих вспомогательных методов принимают исходный итератор и создают новый итератор с другим поведением. Их называют «методами-адаптерами итераторов» (iterator adapter methods).

  • Некоторые методы, например sum и count, потребляют итератор и извлекают из него все элементы.

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

Для дальнейшего изучения

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

enumerate

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let array = ['a', 'b', 'c', 'd', 'e'];

    // По умолчанию перебор массива возвращает только его элементы.
    for letter in array {
        println!("The element is {letter:?}, but what's its index?");
    }

    // `enumerate` возвращает и элемент, и его индекс.
    for (i, letter) in array.iter().enumerate() {
        println!("{letter:?} has index {i}");
    }
}
  • Особенность итераторов Rust и циклов for в том, что итератор по умолчанию не даёт индекс каждого элемента. Это нормально, когда нужны, например, только значения массива, но при переборе часто нужен и индекс каждого элемента.

  • enumerate создаёт итератор, который выдаёт кортеж, где первый элемент — индекс, а второй — элемент исходного итератора.

  • Укажите, что в цикле for для переменной цикла можно использовать шаблон. Это позволяет деструктурировать кортеж, который выдаёт enumerate.

  • Обратите внимание, что сначала пришлось вызвать .iter(): enumerate — метод итератора, а не метод самого типа массива.

collect

Метод collect позволяет построить коллекцию из Iterator.

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let primes = vec![2, 3, 5, 7];
    let prime_squares = primes.into_iter().map(|p| p * p).collect::<Vec<_>>();
    println!("prime_squares: {prime_squares:?}");
}
This slide should take about 5 minutes.
  • Любой итератор можно собрать в Vec, VecDeque или HashSet. Итераторы, которые выдают пары «ключ-значение» (то есть двухэлементные кортежи), можно собирать и в HashMap, и в BTreeMap.

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

  • С «turbofish»: some_iterator.collect::<COLLECTION_TYPE>(), как показано. Сокращение _, использованное здесь, позволяет Rust вывести тип элементов Vec.
  • С выводом типов: let prime_squares: Vec<_> = some_iterator.collect(). Перепишите пример в этой форме.

Для дальнейшего изучения

  • Если студентам интересно, как это работает, можно вспомнить трейт FromIterator, который определяет, как каждый тип коллекции строится из итератора.
  • Помимо базовых реализаций FromIterator для Vec, HashMap и т. д., есть и более специализированные реализации, которые позволяют делать интересные вещи, например превратить Iterator<Item = Result<V, E>> в Result<Vec<V>, E>.
  • Аннотации типов с collect часто нужны потому, что он обобщён по своему возвращаемому типу. Во многих случаях это затрудняет компилятору вывод правильного типа.

Трейт IntoIterator

Трейт Iterator говорит, как итерироваться, когда итератор уже создан. Связанный трейт IntoIterator определяет, как создать итератор для типа. Он используется автоматически циклом for.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct Grid {
    x_coords: Vec<u32>,
    y_coords: Vec<u32>,
}

impl IntoIterator for Grid {
    type Item = (u32, u32);
    type IntoIter = GridIter;
    fn into_iter(self) -> GridIter {
        GridIter { grid: self, i: 0, j: 0 }
    }
}

struct GridIter {
    grid: Grid,
    i: usize,
    j: usize,
}

impl Iterator for GridIter {
    type Item = (u32, u32);

    fn next(&mut self) -> Option<(u32, u32)> {
        if self.i >= self.grid.x_coords.len() {
            self.i = 0;
            self.j += 1;
            if self.j >= self.grid.y_coords.len() {
                return None;
            }
        }
        let res = Some((self.grid.x_coords[self.i], self.grid.y_coords[self.j]));
        self.i += 1;
        res
    }
}

fn main() {
    let grid = Grid { x_coords: vec![3, 5, 7, 9], y_coords: vec![10, 20, 30, 40] };

    for (x, y) in grid {
        println!("point = {x}, {y}");
    }

    // dbg!(&grid.x_coords); // ❌ Сетка была потреблена циклом.
}
This slide should take about 5 minutes.
  • IntoIterator — трейт, благодаря которому работают циклы for. Его реализуют типы коллекций, например Vec<T>, и ссылки на них, например &Vec<T> и &[T]. Его также реализуют диапазоны. Поэтому можно перебирать вектор через for i in some_vec { .. }, но метода some_vec.next() не существует.

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

  • Item: тип элементов, по которым идёт итерация, например i8,
  • IntoIter: тип Iterator, который возвращает метод into_iter.

Обратите внимание, что IntoIter и Item связаны: итератор должен иметь тот же тип Item, а значит, возвращать Option<Item>.

Пример перебирает все комбинации координат x и y.

Раскомментируйте закомментированную строку в main и покажите ошибку компилятора. Обратите внимание, что IntoIterator::into_iter забирает владение self. На следующем слайде показано, как этого избежать, перебирая &Grid через дополнительную реализацию IntoIterator.

IntoIterator для ссылок

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct Grid {
    x_coords: Vec<u32>,
    y_coords: Vec<u32>,
}

impl<'a> IntoIterator for &'a Grid {
    type Item = (&'a u32, &'a u32);
    type IntoIter = GridRefIter<'a>;
    fn into_iter(self) -> GridRefIter<'a> {
        GridRefIter { grid: self, i: 0, j: 0 }
    }
}

struct GridRefIter<'a> {
    grid: &'a Grid,
    i: usize,
    j: usize,
}

impl<'a> Iterator for GridRefIter<'a> {
    type Item = (&'a u32, &'a u32);

    fn next(&mut self) -> Option<Self::Item> {
        if self.i >= self.grid.x_coords.len() {
            self.i = 0;
            self.j += 1;
            if self.j >= self.grid.y_coords.len() {
                return None;
            }
        }
        let res = Some((&self.grid.x_coords[self.i], &self.grid.y_coords[self.j]));
        self.i += 1;
        res
    }
}

fn main() {
    let grid = Grid { x_coords: vec![3, 5, 7, 9], y_coords: vec![10, 20, 30, 40] };

    // Цикл может заимствовать сетку, не забирая владение.
    for (x, y) in &grid {
        println!("point = {x}, {y}");
    }

    // Сетка остаётся доступной после цикла.
    dbg!(&grid.x_coords);
}
  • Реализовав IntoIterator для &Grid, мы можем передать ссылку в цикл for и получить заимствующий итератор. Это позволяет заимствовать объект сетки в цикле, не забирая владение им.

  • Этот playground показывает, что GridIter и GridRefIter можно использовать одновременно.

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

  • Откройте документацию IntoIterator и покажите реализации для [T; N], &[T; N] и &mut [T; N]. Покажите, что они создают разные итераторы с разными типами элементов:

    • Итератор для [T; N] выдаёт T по значению.
    • Итератор для &[T; N] выдаёт &T.
    • Итератор для &mut [T; N] выдаёт &mut T.

Упражнение: Цепочка вычислений

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

Скопируйте следующий код на https://play.rust-lang.org/ и добейтесь прохождения тестов. Используйте выражение-итератор и collect, чтобы построить возвращаемое значение.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Вычисляет разности между элементами `values`, смещёнными на `offset`,
/// переходя от конца `values` к началу.
///
/// Элемент `n` результата равен `values[(n+offset)%len] - values[n]`.
fn offset_differences(offset: usize, values: Vec<i32>) -> Vec<i32> {
    todo!()
}

#[test]
fn test_offset_one() {
    assert_eq!(offset_differences(1, vec![1, 3, 5, 7]), vec![2, 2, 2, -6]);
    assert_eq!(offset_differences(1, vec![1, 3, 5]), vec![2, 2, -4]);
    assert_eq!(offset_differences(1, vec![1, 3]), vec![2, -2]);
}

#[test]
fn test_larger_offsets() {
    assert_eq!(offset_differences(2, vec![1, 3, 5, 7]), vec![4, 4, -4, -4]);
    assert_eq!(offset_differences(3, vec![1, 3, 5, 7]), vec![6, -2, -2, -2]);
    assert_eq!(offset_differences(4, vec![1, 3, 5, 7]), vec![0, 0, 0, 0]);
    assert_eq!(offset_differences(5, vec![1, 3, 5, 7]), vec![2, 2, 2, -6]);
}

#[test]
fn test_degenerate_cases() {
    assert_eq!(offset_differences(1, vec![0]), vec![0]);
    assert_eq!(offset_differences(1, vec![1]), vec![0]);
    let empty: Vec<i32> = vec![];
    assert_eq!(offset_differences(1, empty), vec![]);
}

Решение

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Вычисляет разности между элементами `values`, смещёнными на `offset`,
/// переходя от конца `values` к началу.
///
/// Элемент `n` результата равен `values[(n+offset)%len] - values[n]`.
fn offset_differences(offset: usize, values: Vec<i32>) -> Vec<i32> {
    let a = values.iter();
    let b = values.iter().cycle().skip(offset);
    a.zip(b).map(|(a, b)| *b - *a).collect()
}

#[test]
fn test_offset_one() {
    assert_eq!(offset_differences(1, vec![1, 3, 5, 7]), vec![2, 2, 2, -6]);
    assert_eq!(offset_differences(1, vec![1, 3, 5]), vec![2, 2, -4]);
    assert_eq!(offset_differences(1, vec![1, 3]), vec![2, -2]);
}

#[test]
fn test_larger_offsets() {
    assert_eq!(offset_differences(2, vec![1, 3, 5, 7]), vec![4, 4, -4, -4]);
    assert_eq!(offset_differences(3, vec![1, 3, 5, 7]), vec![6, -2, -2, -2]);
    assert_eq!(offset_differences(4, vec![1, 3, 5, 7]), vec![0, 0, 0, 0]);
    assert_eq!(offset_differences(5, vec![1, 3, 5, 7]), vec![2, 2, 2, -6]);
}

#[test]
fn test_degenerate_cases() {
    assert_eq!(offset_differences(1, vec![0]), vec![0]);
    assert_eq!(offset_differences(1, vec![1]), vec![0]);
    let empty: Vec<i32> = vec![];
    assert_eq!(offset_differences(1, empty), vec![]);
}

Модули

Этот сегмент займёт около 45 минут. Он включает:

СлайдДлительность
Модули3 минуты
Дерево модулей5 минут
Область видимости5 минут
Инкапсуляция5 минут
Использование use, super и self10 минут
Упражнение: Реорганизация графической библиотеки15 минут

Модули

Мы видели, как блоки impl позволяют задавать пространство имён функций для типа.

Аналогично mod позволяет задавать пространство имён типам и функциям:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

mod foo {
    pub fn do_something() {
        println!("In the foo module");
    }
}

mod bar {
    pub fn do_something() {
        println!("In the bar module");
    }
}

fn main() {
    foo::do_something();
    bar::do_something();
}
This slide should take about 3 minutes.
  • Пакеты предоставляют функциональность и включают файл Cargo.toml, который описывает, как собрать набор из одного или нескольких крейтов.
  • Крейты — это дерево модулей: бинарный крейт создаёт исполняемый файл, а библиотечный крейт собирается в библиотеку.
  • Модули определяют организацию и область видимости и являются главным предметом этого раздела.

Дерево модулей

Если не писать содержимое модуля, Rust будет искать его в другом файле:

// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

mod garden;

Это сообщает Rust, что содержимое модуля garden находится в файле garden.rs в том же каталоге, что и файл, объявивший модуль. Если модуль garden аналогично объявляет подмодуль vegetables, его содержимое будет находиться в garden/vegetables.rs.

Корень крейта находится в:

  • src/lib.rs (для библиотечного крейта)
  • src/main.rs (для бинарного крейта)

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

Модули, определённые в файлах, тоже можно документировать с помощью «внутренних doc-комментариев». Они документируют элемент, который их содержит, в данном случае — модуль.

// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

//! Этот модуль реализует сад, включая высокопроизводительную
//! реализацию прорастания семян.

// Объявляем подмодули.
mod plant;
mod seeds;

// Реэкспортируем типы из этого модуля.
pub use plant::Plant;
pub use seeds::SeedPacket;

pub struct Garden {
    plants: Vec<Plant>,
}

/// Сеет заданные пакеты семян.
pub fn sow(seeds: Vec<SeedPacket>) {
    todo!()
}

/// Собирает готовые к сбору овощи и фрукты в саду.
pub fn harvest(garden: &mut Garden) {
    todo!()
}
This slide should take about 5 minutes.
  • До Rust 2018 модули нужно было размещать в module/mod.rs, а не в module.rs, и это по-прежнему рабочий вариант для редакций после 2018 года.

  • Основная причина ввести filename.rs как альтернативу filename/mod.rs в том, что много файлов с именем mod.rs может быть трудно различить в IDE.

  • Для более глубокой вложенности можно использовать каталоги, даже если главный модуль — файл:

    src/
    ├── main.rs
    ├── top_module.rs
    └── top_module/
        └── sub_module.rs
    
  • Место, где Rust будет искать модули, можно изменить с помощью директивы компилятора:

    // Copyright 2022 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    #[path = "some/path.rs"]
    mod some_module;

    Это полезно, например, если вы хотите поместить тесты модуля в файл с именем some_module_test.rs, по аналогии с соглашением в Go.

Область видимости

Модули задают границу приватности:

  • Элементы модуля по умолчанию приватные (это скрывает детали реализации).
  • Родительские и соседние элементы всегда видимы.
  • Иными словами, если элемент виден в модуле foo, он виден во всех потомках foo.
// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

mod outer {
    fn private() {
        println!("outer::private");
    }

    pub fn public() {
        println!("outer::public");
    }

    mod inner {
        fn private() {
            println!("outer::inner::private");
        }

        pub fn public() {
            println!("outer::inner::public");
            super::private();
        }
    }
}

fn main() {
    outer::public();
}
This slide should take about 5 minutes.
  • Используйте ключевое слово pub, чтобы сделать модули публичными.

Кроме того, есть продвинутые спецификаторы pub(...), которые ограничивают область публичной видимости.

  • См. Rust Reference.
  • Настройка видимости pub(crate) — распространённый паттерн.
  • Реже можно дать видимость конкретному пути.
  • В любом случае видимость должна быть предоставлена родительскому модулю (и всем его потомкам).

Видимость и инкапсуляция

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

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use outer::Foo;

mod outer {
    pub struct Foo {
        pub val: i32,
        is_big: bool,
    }

    impl Foo {
        pub fn new(val: i32) -> Self {
            Self { val, is_big: val > 100 }
        }
    }

    pub mod inner {
        use super::Foo;

        pub fn print_foo(foo: &Foo) {
            println!("Is {} big? {}", foo.val, foo.is_big);
        }
    }
}

fn main() {
    let foo = Foo::new(42);
    println!("foo.val = {}", foo.val);
    // let foo = Foo { val: 42, is_big: true };

    outer::inner::print_foo(&foo);
    // println!("Is {} big? {}", foo.val, foo.is_big);
}
This slide should take about 5 minutes.
  • Этот слайд демонстрирует, что приватность полей структур основана на модулях. Студенты, пришедшие из объектно-ориентированных языков, могут привыкнуть к тому, что границей инкапсуляции служат типы, поэтому здесь показано, что Rust ведёт себя иначе, и как всё равно можно добиться инкапсуляции.

  • Обратите внимание, что поле is_big полностью контролируется Foo, что позволяет Foo определять, как оно инициализируется, и обеспечивать любые нужные инварианты (например, что is_big равно true только при val > 100).

  • Покажите, что вспомогательные функции можно определять в том же модуле (включая дочерние модули), чтобы получить доступ к приватным полям и методам типа.

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

  • Перечисления не поддерживают приватность: варианты и данные внутри них всегда публичны.

Для дальнейшего изучения

  • Если студенты хотят узнать больше о приватности (или её отсутствии) в перечислениях, можно упомянуть #[doc(hidden)] и #[non_exhaustive] и показать, как их используют, чтобы ограничить то, что можно делать с перечислением.

  • Приватность модуля по-прежнему применяется, когда блоки impl находятся в других модулях (пример на playground).

Использование use, super и self

Модуль может внести символы из другого модуля в область видимости с помощью use. Обычно в начале каждого модуля вы увидите что-то подобное:

// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::collections::HashSet;
use std::process::abort;

Пути

Пути разрешаются следующим образом:

  1. Как относительный путь:

    • foo или self::foo ссылаются на foo в текущем модуле,
    • super::foo ссылается на foo в родительском модуле.
  2. Как абсолютный путь:

    • crate::foo ссылается на foo в корне текущего крейта,
    • bar::foo ссылается на foo в крейте bar.
This slide should take about 8 minutes.
  • Часто символы «реэкспортируют» по более короткому пути. Например, верхний lib.rs в крейте может содержать

    // Copyright 2022 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    mod storage;
    
    pub use storage::disk::DiskStorage;
    pub use storage::network::NetworkStorage;

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

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

  • Оператор use может содержать подстановочный символ: use std::io::*. Это не рекомендуется, потому что неясно, какие элементы импортированы, а они со временем могут меняться.

Упражнение: Реорганизация графической библиотеки

В этом упражнении вы реорганизуете небольшую реализацию библиотеки для GUI. Эта библиотека определяет трейт Widget, несколько его реализаций и функцию main.

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

Настройка Cargo

Playground Rust поддерживает только один файл, поэтому вам понадобится создать проект Cargo на локальной файловой системе:

cargo init gui-modules
cd gui-modules
cargo run

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

Исходный код

Вот реализация библиотеки GUI в одном модуле:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub trait Widget {
    /// Естественная ширина `self`.
    fn width(&self) -> usize;

    /// Отрисовать виджет в буфер.
    fn draw_into(&self, buffer: &mut dyn std::fmt::Write);

    /// Отрисовать виджет в стандартный вывод.
    fn draw(&self) {
        let mut buffer = String::new();
        self.draw_into(&mut buffer);
        println!("{buffer}");
    }
}

pub struct Label {
    label: String,
}

impl Label {
    fn new(label: &str) -> Label {
        Label { label: label.to_owned() }
    }
}

pub struct Button {
    label: Label,
}

impl Button {
    fn new(label: &str) -> Button {
        Button { label: Label::new(label) }
    }
}

pub struct Window {
    title: String,
    widgets: Vec<Box<dyn Widget>>,
}

impl Window {
    fn new(title: &str) -> Window {
        Window { title: title.to_owned(), widgets: Vec::new() }
    }

    fn add_widget(&mut self, widget: Box<dyn Widget>) {
        self.widgets.push(widget);
    }

    fn inner_width(&self) -> usize {
        std::cmp::max(
            self.title.chars().count(),
            self.widgets.iter().map(|w| w.width()).max().unwrap_or(0),
        )
    }
}

impl Widget for Window {
    fn width(&self) -> usize {
        // Добавить 4 отступа для рамок
        self.inner_width() + 4
    }

    fn draw_into(&self, buffer: &mut dyn std::fmt::Write) {
        let mut inner = String::new();
        for widget in &self.widgets {
            widget.draw_into(&mut inner);
        }

        let inner_width = self.inner_width();

        // TODO: Измените draw_into, чтобы он возвращал Result<(), std::fmt::Error>. Затем используйте
        // оператор ? здесь вместо .unwrap().
        writeln!(buffer, "+-{:-<inner_width$}-+", "").unwrap();
        writeln!(buffer, "| {:^inner_width$} |", self.title).unwrap();
        writeln!(buffer, "+={:=<inner_width$}=+", "").unwrap();
        for line in inner.lines() {
            writeln!(buffer, "| {:inner_width$} |", line).unwrap();
        }
        writeln!(buffer, "+-{:-<inner_width$}-+", "").unwrap();
    }
}

impl Widget for Button {
    fn width(&self) -> usize {
        self.label.width() + 8 // добавим немного отступов
    }

    fn draw_into(&self, buffer: &mut dyn std::fmt::Write) {
        let width = self.width();
        let mut label = String::new();
        self.label.draw_into(&mut label);

        writeln!(buffer, "+{:-<width$}+", "").unwrap();
        for line in label.lines() {
            writeln!(buffer, "|{:^width$}|", line).unwrap();
        }
        writeln!(buffer, "+{:-<width$}+", "").unwrap();
    }
}

impl Widget for Label {
    fn width(&self) -> usize {
        self.label.lines().map(|line| line.chars().count()).max().unwrap_or(0)
    }

    fn draw_into(&self, buffer: &mut dyn std::fmt::Write) {
        writeln!(buffer, "{}", self.label).unwrap();
    }
}

fn main() {
    let mut window = Window::new("Rust GUI Demo 1.23");
    window.add_widget(Box::new(Label::new("This is a small text GUI demo.")));
    window.add_widget(Box::new(Button::new("Click me!")));
    window.draw();
}
This slide and its sub-slides should take about 15 minutes.

Поощряйте студентов разделить код так, как им кажется естественным, и привыкнуть к необходимым объявлениям mod, use и pub. После этого обсудите, какая организация наиболее идиоматична.

Решение

src
├── main.rs
├── widgets
│   ├── button.rs
│   ├── label.rs
│   └── window.rs
└── widgets.rs
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

// ---- src/widgets.rs ----
pub use button::Button;
pub use label::Label;
pub use window::Window;

mod button;
mod label;
mod window;

pub trait Widget {
    /// Естественная ширина `self`.
    fn width(&self) -> usize;

    /// Отрисовать виджет в буфер.
    fn draw_into(&self, buffer: &mut dyn std::fmt::Write);

    /// Отрисовать виджет в стандартный вывод.
    fn draw(&self) {
        let mut buffer = String::new();
        self.draw_into(&mut buffer);
        println!("{buffer}");
    }
}
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

// ---- src/widgets/label.rs ----
use super::Widget;

pub struct Label {
    label: String,
}

impl Label {
    pub fn new(label: &str) -> Label {
        Label { label: label.to_owned() }
    }
}

impl Widget for Label {
    fn width(&self) -> usize {
        // ANCHOR_END: Label-width
        self.label.lines().map(|line| line.chars().count()).max().unwrap_or(0)
    }

    // ANCHOR: Label-draw_into
    fn draw_into(&self, buffer: &mut dyn std::fmt::Write) {
        // ANCHOR_END: Label-draw_into
        writeln!(buffer, "{}", &self.label).unwrap();
    }
}
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

// ---- src/widgets/button.rs ----
use super::{Label, Widget};

pub struct Button {
    label: Label,
}

impl Button {
    pub fn new(label: &str) -> Button {
        Button { label: Label::new(label) }
    }
}

impl Widget for Button {
    fn width(&self) -> usize {
        // ANCHOR_END: Button-width
        self.label.width() + 8 // добавим немного отступов
    }

    // ANCHOR: Button-draw_into
    fn draw_into(&self, buffer: &mut dyn std::fmt::Write) {
        // ANCHOR_END: Button-draw_into
        let width = self.width();
        let mut label = String::new();
        self.label.draw_into(&mut label);

        writeln!(buffer, "+{:-<width$}+", "").unwrap();
        for line in label.lines() {
            writeln!(buffer, "|{:^width$}|", &line).unwrap();
        }
        writeln!(buffer, "+{:-<width$}+", "").unwrap();
    }
}
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

// ---- src/widgets/window.rs ----
use super::Widget;

pub struct Window {
    title: String,
    widgets: Vec<Box<dyn Widget>>,
}

impl Window {
    pub fn new(title: &str) -> Window {
        Window { title: title.to_owned(), widgets: Vec::new() }
    }

    pub fn add_widget(&mut self, widget: Box<dyn Widget>) {
        self.widgets.push(widget);
    }

    fn inner_width(&self) -> usize {
        std::cmp::max(
            self.title.chars().count(),
            self.widgets.iter().map(|w| w.width()).max().unwrap_or(0),
        )
    }
}

impl Widget for Window {
    fn width(&self) -> usize {
        // ANCHOR_END: Window-width
        // Добавить 4 отступа для рамок
        self.inner_width() + 4
    }

    // ANCHOR: Window-draw_into
    fn draw_into(&self, buffer: &mut dyn std::fmt::Write) {
        // ANCHOR_END: Window-draw_into
        let mut inner = String::new();
        for widget in &self.widgets {
            widget.draw_into(&mut inner);
        }

        let inner_width = self.inner_width();

        // TODO: после изучения обработки ошибок вы можете изменить
        // draw_into так, чтобы он возвращал Result<(), std::fmt::Error>. Затем используйте
        // оператор ? здесь вместо .unwrap().
        writeln!(buffer, "+-{:-<inner_width$}-+", "").unwrap();
        writeln!(buffer, "| {:^inner_width$} |", &self.title).unwrap();
        writeln!(buffer, "+={:=<inner_width$}=+", "").unwrap();
        for line in inner.lines() {
            writeln!(buffer, "| {:inner_width$} |", line).unwrap();
        }
        writeln!(buffer, "+-{:-<inner_width$}-+", "").unwrap();
    }
}
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

// ---- src/main.rs ----
mod widgets;

use widgets::{Button, Label, Widget, Window};

fn main() {
    let mut window = Window::new("Rust GUI Demo 1.23");
    window.add_widget(Box::new(Label::new("This is a small text GUI demo.")));
    window.add_widget(Box::new(Button::new("Click me!")));
    window.draw();
}

Тестирование

Этот сегмент займёт около 45 минут. Он включает:

СлайдДлительность
Модульные тесты5 минут
Интеграционные и прочие тесты5 минут
Ошибки компиляции и Clippy3 минуты
Упражнение: Алгоритм Лу́на30 минут

Модульные тесты

Rust и Cargo поставляются с простым фреймворком модульных тестов. Тесты помечаются #[test]. Модульные тесты часто помещают во вложенный модуль tests и используют #[cfg(test)], чтобы компилировать их только при сборке тестов.

// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn first_word(text: &str) -> &str {
    match text.find(' ') {
        Some(idx) => &text[..idx],
        None => &text,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_empty() {
        assert_eq!(first_word(""), "");
    }

    #[test]
    fn test_single_word() {
        assert_eq!(first_word("Hello"), "Hello");
    }

    #[test]
    fn test_multiple_words() {
        assert_eq!(first_word(Привет, мир!), "Hello");
    }
}
  • Это позволяет модульно тестировать приватные вспомогательные функции.
  • Атрибут #[cfg(test)] активен только при запуске cargo test.

Интеграционные и прочие тесты

Интеграционные тесты

Если вы хотите протестировать библиотеку как клиент, используйте интеграционный тест.

Создайте файл .rs в каталоге tests/:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

// tests/my_library.rs
use my_library::init;

#[test]
fn test_init() {
    assert!(init().is_ok());
}

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

Тесты документации

В Rust есть встроенная поддержка тестов документации:

#![allow(unused)]
fn main() {
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Укорачивает строку до заданной длины.
///
/// ```
/// # use playground::shorten_string;
/// assert_eq!(shorten_string("Hello World", 5), "Hello");
/// assert_eq!(shorten_string("Hello World", 20), "Hello World");
/// ```
pub fn shorten_string(s: &str, length: usize) -> &str {
    &s[..std::cmp::min(length, s.len())]
}
}
  • Блоки кода в комментариях /// автоматически воспринимаются как код Rust.
  • Код будет скомпилирован и выполнен в рамках cargo test.
  • Добавление # в код скроет его в документации, но код всё равно будет скомпилирован и выполнен.
  • Проверьте приведённый выше код на Rust Playground.

Ошибки компиляции и Clippy

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[deny(clippy::cast_possible_truncation)]
fn main() {
    let mut x = 3;
    while (x < 70000) {
        x *= 2;
    }
    println!("X probably fits in a u16, right? {}", x as u16);
}
This slide should take about 3 minutes.

Здесь видны линты компилятора, но линтов clippy нет. Запустите clippy на сайте playground, чтобы показать предупреждения clippy. У clippy подробная документация к линтам, и он постоянно добавляет новые линты (в том числе линты, которые по умолчанию считаются ошибками).

Обратите внимание, что ошибки или предупреждения с help: ... можно исправить командой cargo fix или через редактор.

Упражнение: Алгоритм Лу́на

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

  • Игнорируйте все пробелы. Отвергайте числа, в которых меньше двух цифр. Отвергайте буквы и другие нецифровые символы.

  • Двигаясь справа налево, удвойте каждую вторую цифру: для числа 1234 удваиваем 3 и 1. Для числа 98765 удваиваем 6 и 8.

  • После удвоения цифры, если результат больше 9, сложите его цифры. Так удвоение 7 даёт 14, а затем 1 + 4 = 5.

  • Сложите все неудвоенные и удвоенные цифры.

  • Номер карты действителен, если сумма заканчивается на 0.

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

Скопируйте текст программы ниже в https://play.rust-lang.org/ и напишите дополнительные тесты, раскрывающие недочёты реализации. Исправьте все обнаруженные проблемы.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub fn luhn(cc_number: &str) -> bool {
    let mut sum = 0;
    let mut double = false;

    for c in cc_number.chars().rev() {
        if let Some(digit) = c.to_digit(10) {
            if double {
                let double_digit = digit * 2;
                sum +=
                    if double_digit > 9 { double_digit - 9 } else { double_digit };
            } else {
                sum += digit;
            }
            double = !double;
        } else {
            continue;
        }
    }

    sum % 10 == 0
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn test_valid_cc_number() {
        assert!(luhn("4263 9826 4026 9299"));
        assert!(luhn("4539 3195 0343 6467"));
        assert!(luhn("7992 7398 713"));
    }

    #[test]
    fn test_invalid_cc_number() {
        assert!(!luhn("4223 9826 4026 9299"));
        assert!(!luhn("4539 3195 0343 6476"));
        assert!(!luhn("8273 1232 7352 0569"));
    }
}

Решение

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub fn luhn(cc_number: &str) -> bool {
    let mut sum = 0;
    let mut double = false;
    let mut digits = 0;

    for c in cc_number.chars().rev() {
        if let Some(digit) = c.to_digit(10) {
            digits += 1;
            if double {
                let double_digit = digit * 2;
                sum +=
                    if double_digit > 9 { double_digit - 9 } else { double_digit };
            } else {
                sum += digit;
            }
            double = !double;
        } else if c.is_whitespace() {
            // Новое: принимаем пробелы.
            continue;
        } else {
            // Новое: отвергаем все остальные символы.
            return false;
        }
    }

    // Новое: проверяем, что у нас есть хотя бы две цифры
    digits >= 2 && sum % 10 == 0
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn test_valid_cc_number() {
        assert!(luhn("4263 9826 4026 9299"));
        assert!(luhn("4539 3195 0343 6467"));
        assert!(luhn("7992 7398 713"));
    }

    #[test]
    fn test_invalid_cc_number() {
        assert!(!luhn("4223 9826 4026 9299"));
        assert!(!luhn("4539 3195 0343 6476"));
        assert!(!luhn("8273 1232 7352 0569"));
    }

    #[test]
    fn test_non_digit_cc_number() {
        assert!(!luhn("foo"));
        assert!(!luhn("foo 0 0"));
    }

    #[test]
    fn test_empty_cc_number() {
        assert!(!luhn(""));
        assert!(!luhn(" "));
        assert!(!luhn("  "));
        assert!(!luhn("    "));
    }

    #[test]
    fn test_single_digit_cc_number() {
        assert!(!luhn("0"));
    }

    #[test]
    fn test_two_digit_cc_number() {
        assert!(luhn(" 0 0 "));
    }
}

Добро пожаловать

Включая 10-минутные перерывы, это занятие займёт около 2 часов 20 минут. Оно включает:

СегментДлительность
Обработка ошибок55 минут
Небезопасный код в Rust1 час 15 минут

Обработка ошибок

Этот сегмент займёт около 55 минут. Он включает:

СлайдДлительность
Паники3 минуты
Result5 минут
Проброс ошибок5 минут
Преобразования через Try5 минут
thiserror5 минут
Динамические типы ошибок5 минут
anyhow5 минут
Упражнение: Замена на Result20 минут

Паники

В случае фатальной ошибки времени выполнения Rust вызывает «панику»:

// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let v = vec![10, 20, 30];
    dbg!(v[100]);
}
  • Паника предназначена для неисправимых и неожиданных ошибок.
    • Паника — симптом ошибки в программе.
    • Ошибки времени выполнения, например неудачная проверка границ, могут вызвать панику.
    • Утверждения (например, assert!) вызывают панику при неудаче.
    • Панику для конкретных случаев можно вызвать с помощью макроса panic!.
  • Паника «раскручивает» стек (unwind), освобождая значения так же, как если бы функции вернули управление.
  • Если аварийное завершение недопустимо, используйте API, которые не паникуют (например, Vec::get).
This slide should take about 3 minutes.

По умолчанию паника приводит к раскручиванию стека. Раскручивание можно перехватить:

// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::panic;

fn main() {
    let result = panic::catch_unwind(|| "No problem here!");
    dbg!(result);

    let result = panic::catch_unwind(|| {
        panic!("oh no!");
    });
    dbg!(result);
}
  • Перехват — необычная практика; не пытайтесь реализовать исключения с помощью catch_unwind!
  • Это может быть полезно в серверах, которые должны продолжать работу, даже если один запрос завершился аварийно.
  • Это не сработает, если в вашем Cargo.toml задано panic = 'abort'.

Result

Нашим основным механизмом обработки ошибок в Rust является перечисление Result, которое мы кратко видели при обсуждении типов стандартной библиотеки.

// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::fs::File;
use std::io::Read;

fn main() {
    let open_result: Result<File, std::io::Error> = File::open("diary.txt");
    match open_result {
        Ok(mut file) => {
            let mut contents = String::new();
            if let Ok(bytes) = file.read_to_string(&mut contents) {
                println!("Dear diary: {contents} ({bytes} bytes)");
            } else {
                println!("Could not read file content");
            }
        }
        Err(err) => {
            println!("The diary could not be opened: {err}");
        }
    }
}
This slide should take about 5 minutes.
  • Result имеет два варианта: Ok, который содержит значение успеха, и Err, который содержит значение ошибки какого-либо вида.

  • То, может ли функция вернуть ошибку, закодировано в сигнатуре её типа: функция возвращает значение Result.

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

Для дальнейшего изучения

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

Исключения

  • Многие языки используют исключения, например C++, Java, Python.

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

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

Номера ошибок

  • В некоторых языках функции возвращают номер ошибки (или другое значение ошибки) отдельно от успешного результата функции. Примеры — C и Go.

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

Проброс ошибок

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

match some_expression {
    Ok(value) => value,
    Err(err) => return Err(err),
}

в гораздо более простую:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

some_expression?

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::io::Read;
use std::{fs, io};

fn read_username(path: &str) -> Result<String, io::Error> {
    let username_file_result = fs::File::open(path);
    let mut username_file = match username_file_result {
        Ok(file) => file,
        Err(err) => return Err(err),
    };

    let mut username = String::new();
    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(err) => Err(err),
    }
}

fn main() {
    //fs::write("config.dat", "alice").unwrap();
    let username = read_username("config.dat");
    println!("username or error: {username:?}");
}
This slide should take about 5 minutes.

Упростите функцию read_username, используя ?.

Ключевые моменты:

  • Переменная username может быть либо Ok(string), либо Err(error).
  • С помощью вызова fs::write проверьте разные сценарии: нет файла, пустой файл, файл с именем пользователя.
  • Обратите внимание, что main может возвращать Result<(), E>, если E реализует std::process::Termination. На практике это означает, что E реализует Debug. При ошибке исполняемый файл выведет вариант Err и вернёт ненулевой код завершения.

Преобразования через Try

Фактическое раскрытие ? немного сложнее, чем показано ранее:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

expression?

работает так же, как

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

match expression {
    Ok(value) => value,
    Err(err)  => return Err(From::from(err)),
}

Вызов From::from здесь означает, что мы пытаемся преобразовать тип ошибки в тип, возвращаемый функцией. Это упрощает инкапсуляцию ошибок в более высокоуровневые ошибки.

Пример

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::error::Error;
use std::io::Read;
use std::{fmt, fs, io};

#[derive(Debug)]
enum ReadUsernameError {
    IoError(io::Error),
    EmptyUsername,
}

impl From<io::Error> for ReadUsernameError {
    fn from(err: io::Error) -> Self {
        Self::IoError(err)
    }
}

fn read_username(path: &str) -> Result<String, ReadUsernameError> {
    let mut username = String::with_capacity(100);
    fs::File::open(path)?.read_to_string(&mut username)?;
    if username.is_empty() {
        return Err(ReadUsernameError::EmptyUsername);
    }
    Ok(username)
}

fn main() {
    //std::fs::write("config.dat", "").unwrap();
    let username = read_username("config.dat");
    println!("username or error: {username:?}");
}
This slide should take about 5 minutes.

Оператор ? должен возвращать значение, совместимое с возвращаемым типом функции. Для Result это значит, что типы ошибок должны быть совместимы. Функция, возвращающая Result<T, ErrorOuter>, может использовать ? на значении типа Result<U, ErrorInner> только если ErrorOuter и ErrorInner — один и тот же тип или если ErrorOuter реализует From<ErrorInner>.

Распространённая альтернатива реализации From — Result::map_err, особенно когда преобразование происходит только в одном месте.

Для Option такого требования совместимости нет. Функция, возвращающая Option<T>, может использовать оператор ? на Option<U> для произвольных типов T и U.

Функция, возвращающая Result, не может использовать ? на Option, и наоборот. Однако Option::ok_or превращает Option в Result, а Result::ok превращает Result в Option.

Совместимость типов ошибок

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

  • Реализуйте трейт Error из стандартной библиотеки.
  • Получите Debug через derive и реализуйте Display (это требуется для Error).
  • Реализуйте преобразования From для базовых типов ошибок, чтобы поддержать автоматическое преобразование типов ошибок с помощью ?.
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::error::Error;
use std::io::Read;
use std::{fmt, fs, io};

#[derive(Debug)]
enum ReadUsernameError {
    IoError(io::Error),
    EmptyUsername,
}

impl Error for ReadUsernameError {}

impl fmt::Display for ReadUsernameError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Self::IoError(e) => write!(f, "I/O error: {e}"),
            Self::EmptyUsername => write!(f, "Username file was empty"),
        }
    }
}

impl From<io::Error> for ReadUsernameError {
    fn from(err: io::Error) -> Self {
        Self::IoError(err)
    }
}
  • Любой тип можно использовать как тип ошибки для Result: нет требований к трейтам, подходят и структуры, и перечисления, а когда ошибке не нужно нести никаких данных, можно даже использовать пустые типы нулевого размера, например ().

  • Хотя строгих требований к типам ошибок нет, для совместимости с экосистемой есть несколько соглашений, которым стоит следовать.

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

  • Стандартная библиотека предоставляет маркерный трейт Error, чтобы показать, что тип представляет ошибку.

  • Error зависит от Debug (который можно получить через derive) и Display (который нужно реализовать вручную). Они гарантируют, что ошибку всегда можно превратить в строку для логирования или показа пользователю.

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

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

thiserror

Крейт thiserror предоставляет макросы, которые помогают избежать шаблонного кода при определении типов ошибок. Он предоставляет макросы derive, которые помогают реализовать From<T>, Display и трейт Error.

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::io::Read;
use std::{fs, io};
use thiserror::Error;

#[derive(Debug, Error)]
enum ReadUsernameError {
    #[error("I/O error: {0}")]
    IoError(#[from] io::Error),

    #[error("Found no username in {0}")]
    EmptyUsername(String),
}

fn read_username(path: &str) -> Result<String, ReadUsernameError> {
    let mut username = String::with_capacity(100);
    fs::File::open(path)?.read_to_string(&mut username)?;
    if username.is_empty() {
        return Err(ReadUsernameError::EmptyUsername(String::from(path)));
    }
    Ok(username)
}

fn main() {
    //fs::write("config.dat", "").unwrap();
    match read_username("config.dat") {
        Ok(username) => println!("Username: {username}"),
        Err(err) => println!("Error: {err}"),
    }
}
This slide should take about 5 minutes.
  • Макрос derive Error предоставляется thiserror и имеет множество полезных атрибутов, которые помогают компактно определять типы ошибок.
  • Сообщение из #[error] используется для получения реализации Display.
  • Обратите внимание, что макрос derive (thiserror::)Error, хотя и приводит к реализации трейта (std::error::)Error, — это не то же самое: трейты и макросы не имеют общего пространства имён.
  • thiserror также поддерживает derive Error для структур, если типу ошибки нужно представлять только один случай ошибки.

Динамические типы ошибок

Иногда нужно разрешить возвращать ошибку любого типа, не написав собственное перечисление, которое покрывает все возможности. Трейт std::error::Error упрощает создание объекта трейта, который может содержать любую ошибку.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::error::Error;
use std::fs;
use std::io::Read;

fn read_count(path: &str) -> Result<i32, Box<dyn Error>> {
    let mut count_str = String::new();
    fs::File::open(path)?.read_to_string(&mut count_str)?;
    let count: i32 = count_str.parse()?;
    Ok(count)
}

fn main() {
    fs::write("count.dat", "1i3").unwrap();
    match read_count("count.dat") {
        Ok(count) => println!("Count: {count}"),
        Err(err) => println!("Error: {err}"),
    }
}
This slide should take about 5 minutes.

Функция read_count может вернуть std::io::Error (из файловых операций) или std::num::ParseIntError (из String::parse).

Упаковка ошибок в box экономит код, но лишает возможности аккуратно обрабатывать разные случаи ошибок по-разному в программе. Поэтому обычно не стоит использовать Box<dyn Error> в публичном API библиотеки, но это может быть хорошим вариантом в программе, где нужно просто вывести сообщение об ошибке.

Не забудьте реализовать трейт std::error::Error при определении собственного типа ошибки, чтобы его можно было упаковать в box.

anyhow

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

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

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use anyhow::{Context, Result, bail};
use std::fs;
use std::io::Read;
use thiserror::Error;

#[derive(Clone, Debug, Eq, Error, PartialEq)]
#[error("Found no username in {0}")]
struct EmptyUsernameError(String);

fn read_username(path: &str) -> Result<String> {
    let mut username = String::with_capacity(100);
    fs::File::open(path)
        .with_context(|| format!("Failed to open {path}"))?
        .read_to_string(&mut username)
        .context("Failed to read")?;
    if username.is_empty() {
        bail!(EmptyUsernameError(path.to_string()));
    }
    Ok(username)
}

fn main() {
    //fs::write("config.dat", "").unwrap();
    match read_username("config.dat") {
        Ok(username) => println!("Username: {username}"),
        Err(err) => println!("Error: {err}"),
    }
}
This slide should take about 5 minutes.
  • anyhow::Error по сути является обёрткой вокруг Box<dyn Error>. Поэтому он снова обычно не лучший выбор для публичного API библиотеки, но широко используется в приложениях.
  • anyhow::Result<V> — псевдоним типа для Result<V, anyhow::Error>.
  • Функциональность, которую предоставляет anyhow::Error, может быть знакома разработчикам на Go: она ведёт себя похоже на тип error в Go, а Result<T, anyhow::Error> очень похож на (T, error) в Go (по соглашению значимым является только один элемент пары).
  • anyhow::Context — трейт, реализованный для стандартных типов Result и Option. Чтобы стали доступны .context() и .with_context() для этих типов, необходимо use anyhow::Context.

Для дальнейшего изучения

  • anyhow::Error поддерживает приведение к конкретному типу (downcast), как std::any::Any: при желании можно извлечь конкретный тип ошибки, хранящийся внутри, для проверки с помощью Error::downcast.

Упражнение: Замена на Result

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Операция, которую нужно выполнить над двумя подвыражениями.
#[derive(Debug)]
enum Operation {
    Add,
    Sub,
    Mul,
    Div,
}

/// Выражение в виде дерева.
#[derive(Debug)]
enum Expression {
    /// Операция над двумя подвыражениями.
    Op { op: Operation, left: Box<Expression>, right: Box<Expression> },

    /// Литеральное значение
    Value(i64),
}

#[derive(PartialEq, Eq, Debug)]
struct DivideByZeroError;

// Исходная реализация вычислителя выражений. Обновите её, чтобы
// возвращать `Result` и создавать ошибку при делении на 0.
fn eval(e: Expression) -> i64 {
    match e {
        Expression::Op { op, left, right } => {
            let left = eval(*left);
            let right = eval(*right);
            match op {
                Operation::Add => left + right,
                Operation::Sub => left - right,
                Operation::Mul => left * right,
                Operation::Div => if right != 0 {
                    left / right
                } else {
                    panic!("Cannot divide by zero!");
                },
            }
        }
        Expression::Value(v) => v,
    }
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn test_error() {
        assert_eq!(
            eval(Expression::Op {
                op: Operation::Div,
                left: Box::new(Expression::Value(99)),
                right: Box::new(Expression::Value(0)),
            }),
            Err(DivideByZeroError)
        );
    }

    #[test]
    fn test_ok() {
        let expr = Expression::Op {
            op: Operation::Sub,
            left: Box::new(Expression::Value(20)),
            right: Box::new(Expression::Value(10)),
        };
        assert_eq!(eval(expr), Ok(10));
    }
}
This slide and its sub-slides should take about 20 minutes.
  • Стартовый код здесь не совсем такой же, как решение предыдущего упражнения: мы добавили явную панику, чтобы показать студентам, где находится случай ошибки. Укажите на это, если студенты запутаются.

Решение

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Операция, которую нужно выполнить над двумя подвыражениями.
#[derive(Debug)]
enum Operation {
    Add,
    Sub,
    Mul,
    Div,
}

/// Выражение в виде дерева.
#[derive(Debug)]
enum Expression {
    /// Операция над двумя подвыражениями.
    Op { op: Operation, left: Box<Expression>, right: Box<Expression> },

    /// Литеральное значение
    Value(i64),
}

#[derive(PartialEq, Eq, Debug)]
struct DivideByZeroError;

fn eval(e: Expression) -> Result<i64, DivideByZeroError> {
    match e {
        Expression::Op { op, left, right } => {
            let left = eval(*left)?;
            let right = eval(*right)?;
            Ok(match op {
                Operation::Add => left + right,
                Operation::Sub => left - right,
                Operation::Mul => left * right,
                Operation::Div => {
                    if right == 0 {
                        return Err(DivideByZeroError);
                    } else {
                        left / right
                    }
                }
            })
        }
        Expression::Value(v) => Ok(v),
    }
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn test_error() {
        assert_eq!(
            eval(Expression::Op {
                op: Operation::Div,
                left: Box::new(Expression::Value(99)),
                right: Box::new(Expression::Value(0)),
            }),
            Err(DivideByZeroError)
        );
    }

    #[test]
    fn test_ok() {
        let expr = Expression::Op {
            op: Operation::Sub,
            left: Box::new(Expression::Value(20)),
            right: Box::new(Expression::Value(10)),
        };
        assert_eq!(eval(expr), Ok(10));
    }
}
  • Тип возвращаемого значения Result: сигнатура функции меняется, чтобы возвращать Result<i64, DivideByZeroError>. Такая явная сигнатура типа заставляет вызывающий код обрабатывать возможность ошибки.
  • Оператор ?: мы используем ? в рекурсивных вызовах: eval(*left)?. Это аккуратно передаёт ошибки дальше. Если eval возвращает Err, функция немедленно возвращает этот Err. Если возвращается Ok(v), v присваивается left (или right).
  • Оборачивание в Ok: успешные результаты должны быть обёрнуты в Ok(...).
  • Обработка деления на ноль: мы явно проверяем right == 0 и возвращаем Err(DivideByZeroError). Это заменяет панику из исходного кода.
  • Укажите, что DivideByZeroError — структура-единица (без полей), и этого достаточно, потому что никакой дополнительный контекст об ошибке здесь не нужен.
  • Обсудите, как ? делает обработку ошибок почти такой же краткой, как исключения, но с явным управлением потоком выполнения.

Небезопасный код в Rust

Этот сегмент займёт около 1 часа 15 минут. Он включает:

СлайдДлительность
Небезопасные возможности5 минут
Разыменование сырых указателей10 минут
Изменяемые статические переменные5 минут
Объединения5 минут
Небезопасные функции15 минут
Небезопасные трейты5 минут
Упражнение: Обёртка интерфейса внешних функций30 минут

Небезопасный код в Rust

В языке Rust есть две части:

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

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

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

Небезопасный Rust даёт доступ к пяти новым возможностям:

  • Разыменование сырых указателей.
  • Доступ к изменяемым статическим переменным и их изменение.
  • Доступ к полям union.
  • Вызов unsafe функций, включая функции extern.
  • Реализация unsafe трейтов.

Далее кратко рассмотрим возможности небезопасного Rust. Полные подробности см. в главе 19.1 The Rust Book и в Rustonomicon.

This slide should take about 5 minutes.

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

Разыменование сырых указателей

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let mut x = 10;

    let p1: *mut i32 = &raw mut x;
    let p2 = p1 as *const i32;

    // SAFETY: p1 и p2 созданы взятием сырых указателей на локальную переменную, поэтому
    // гарантированно не равны null, выровнены и указывают на один объект,
    // выделенный (на стеке).
    //
    // Объект, на который указывают сырые указатели, живёт всё время выполнения функции, поэтому
    // он не освобождается, пока существуют сырые указатели. Через ссылки к нему
    // не обращаются, пока существуют сырые указатели, и из других потоков одновременно к нему тоже не обращаются.
    unsafe {
        dbg!(*p1);
        *p1 = 6;
        // Изменение может корректно наблюдаться через сырой указатель, как в C.
        dbg!(*p2);
    }

    // НЕКОРРЕКТНО. НЕ ДЕЛАЙТЕ ТАК.
    /*
    let r: &i32 = unsafe { &*p1 };
    dbg!(r);
    x = 50;
    dbg!(r); // Объект, лежащий в основе ссылки, был изменён. Это UB.
    */
}
This slide should take about 10 minutes.

Хорошей практикой (и требованием руководства по стилю Rust для Android) является написание комментария к каждому блоку unsafe, объясняющего, как код внутри него удовлетворяет требованиям безопасности выполняемых небезопасных операций.

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

  • Указатель не должен быть null.
  • Указатель должен быть разыменовываемым (в пределах границ одного выделенного объекта).
  • Объект не должен быть освобождён.
  • Не должно быть одновременного доступа к одному и тому же месту.
  • Если указатель получен приведением ссылки, базовый объект должен быть живым, и ни одна ссылка не может использоваться для доступа к памяти.

В большинстве случаев указатель также должен быть правильно выровнен.

Раздел «UNSOUND» показывает пример распространённого вида ошибки UB: наивное взятие ссылки на результат разыменования сырого указателя обходит знание компилятора о том, на какой объект на самом деле указывает ссылка. Поэтому анализатор заимствований не «замораживает» x, и мы можем изменить его, несмотря на существование ссылки на него. Создание ссылки из указателя требует большой осторожности.

Изменяемые статические переменные

Безопасно читать неизменяемую статическую переменную:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

static HELLO_WORLD: &str = "Hello, world!";

fn main() {
    println!("HELLO_WORLD: {HELLO_WORLD}");
}

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

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

static mut COUNTER: u32 = 0;

fn add_to_counter(inc: u32) {
    // SAFETY: Нет других потоков, которые могли бы обращаться к `COUNTER`.
    unsafe {
        COUNTER += inc;
    }
}

fn main() {
    add_to_counter(42);

    // SAFETY: Нет других потоков, которые могли бы обращаться к `COUNTER`.
    unsafe {
        dbg!(COUNTER);
    }
}
This slide should take about 5 minutes.
  • Программа здесь корректна, потому что она однопоточная. Однако компилятор Rust рассуждает о функциях по отдельности, поэтому не может считать это гарантированным. Попробуйте убрать unsafe и посмотрите, как компилятор объяснит, что доступ к изменяемой статической переменной из нескольких потоков — неопределённое поведение.
  • Редакция Rust 2024 идёт дальше и делает доступ к изменяемой статической переменной по ссылке ошибкой по умолчанию.
  • Использование изменяемой статической переменной редко оправдано: вместо этого лучше использовать внутреннюю изменяемость.
  • Иногда это может понадобиться в низкоуровневом коде no_std, например при реализации аллокатора кучи или при работе с некоторыми API C. В таком случае следует использовать указатели, а не ссылки.

Объединения

Объединения похожи на перечисления, но активное поле нужно отслеживать самостоятельно:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[repr(C)]
union MyUnion {
    i: u8,
    b: bool,
}

fn main() {
    let u = MyUnion { i: 42 };
    println!("int: {}", unsafe { u.i });
    println!("bool: {}", unsafe { u.b }); // Неопределённое поведение!
}
This slide should take about 5 minutes.

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

Если нужно просто переинтерпретировать байты как другой тип, вероятно, вам нужен std::mem::transmute или безопасная обёртка, например крейт zerocopy.

Небезопасные функции

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

Небезопасные функции могут происходить из двух мест:

  • Функции Rust, объявленные как unsafe.
  • Небезопасные внешние функции в блоках extern "C".
This slide and its sub-slides should take about 15 minutes.

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

Небезопасные функции Rust

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Меняет местами значения, на которые указывают заданные указатели.
///
/// # Safety
///
/// Указатели должны быть допустимыми, правильно выровненными и не должны иначе
/// использоваться на протяжении вызова функции.
unsafe fn swap(a: *mut u8, b: *mut u8) {
    // SAFETY: Вызывающий код обещал, что указатели допустимы, правильно выровнены
    // и не имеют другого доступа.
    unsafe {
        let temp = *a;
        *a = *b;
        *b = temp;
    }
}

fn main() {
    let mut a = 42;
    let mut b = 66;

    // SAFETY: Указатели должны быть допустимыми, выровненными и уникальными, потому что они
    // получены из ссылок.
    unsafe {
        swap(&mut a, &mut b);
    }

    println!("a = {}, b = {}", a, b);
}

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

Обратите внимание, что Rust 2021 и более ранние редакции разрешают небезопасный код внутри небезопасной функции без блока unsafe. В редакции 2024 это изменилось. В старых редакциях это можно запретить с помощью #[deny(unsafe_op_in_unsafe_fn)]. Попробуйте добавить и посмотрите, что произойдёт.

Небезопасные внешние функции

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::ffi::c_char;

unsafe extern "C" {
    // `abs` не работает с указателями и не имеет требований безопасности.
    safe fn abs(input: i32) -> i32;

    /// # Safety
    ///
    /// `s` должен быть указателем на строку C, оканчивающуюся NUL, которая допустима и
    /// не изменяется на протяжении этого вызова функции.
    unsafe fn strlen(s: *const c_char) -> usize;
}

fn main() {
    println!("Absolute value of -3 according to C: {}", abs(-3));

    // SAFETY: Мы передаём указатель на строковый литерал C, который допустим на
    // протяжении всей работы программы.
    unsafe {
        println!("String length: {}", strlen(c"String".as_ptr()));
    }
}
  • Раньше Rust считал все внешние функции небезопасными, но это изменилось в Rust 1.82 с появлением блоков unsafe extern.
  • abs должна быть явно помечена как safe, потому что это внешняя функция (FFI). Вызов внешних функций представляет проблему только тогда, когда эти функции делают что-то с указателями, что может нарушить модель памяти Rust, но в целом любая функция C может иметь неопределённое поведение при любых произвольных обстоятельствах.
  • "C" в этом примере — это ABI; доступны и другие ABI.
  • Обратите внимание, что нет проверки того, что сигнатура функции Rust совпадает с сигнатурой определения функции, — это ответственность разработчика!

Вызов небезопасных функций

Несоблюдение требований безопасности нарушает безопасность памяти!

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug)]
#[repr(C)]
struct KeyPair {
    pk: [u16; 4], // 8 байт
    sk: [u16; 4], // 8 байт
}

const PK_BYTE_LEN: usize = 8;

fn log_public_key(pk_ptr: *const u16) {
    let pk: &[u16] = unsafe { std::slice::from_raw_parts(pk_ptr, PK_BYTE_LEN) };
    println!("{pk:?}");
}

fn main() {
    let key_pair = KeyPair { pk: [1, 2, 3, 4], sk: [0, 0, 42, 0] };
    log_public_key(key_pair.pk.as_ptr());
}

Всегда добавляйте комментарий о безопасности (// SAFETY:) к каждому блоку unsafe. Он должен объяснять, почему код действительно безопасен. В этом примере комментарий о безопасности отсутствует, и код некорректен.

Ключевые моменты:

  • Второй аргумент slice::from_raw_parts — это количество элементов, а не байтов! В этом примере демонстрируется неожиданное поведение: чтение за пределами одного массива и попадание в другой.
  • Это неопределённое поведение, потому что мы читаем за пределами массива, из которого был получен указатель.
  • log_public_key должна быть unsafe, потому что pk_ptr должен удовлетворять определённым предусловиям, чтобы избежать неопределённого поведения. Безопасную функцию, которая может вызвать неопределённое поведение, называют unsound (некорректной). Что должна говорить её документация о безопасности?
  • В стандартной библиотеке есть ряд низкоуровневых небезопасных функций. По возможности предпочитайте безопасные альтернативы!
  • Если вы используете небезопасную функцию как оптимизацию, обязательно добавьте бенчмарк, чтобы продемонстрировать выигрыш.

Реализация небезопасных трейтов

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

Например, в крейте zerocopy есть небезопасный трейт, который выглядит примерно так:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::{mem, slice};

/// ...
/// # Safety
/// Тип должен иметь определённое представление и не содержать выравнивающих байтов (padding).
pub unsafe trait IntoBytes {
    fn as_bytes(&self) -> &[u8] {
        let len = mem::size_of_val(self);
        let slf: *const Self = self;
        unsafe { slice::from_raw_parts(slf.cast::<u8>(), len) }
    }
}

// SAFETY: `u32` имеет определённое представление и не содержит выравнивающих байтов (padding).
unsafe impl IntoBytes for u32 {}
This slide should take about 5 minutes.

У трейта в Rustdoc должен быть раздел # Safety, объясняющий требования для безопасной реализации трейта.

Сам раздел о безопасности для IntoBytes гораздо длиннее и сложнее.

Встроенные трейты Send и Sync — небезопасные.

Безопасная обёртка FFI

В Rust хорошая поддержка вызова функций через интерфейс внешних функций (FFI). Мы используем её, чтобы построить безопасную обёртку над функциями libc, которые вы бы использовали из C для чтения имён файлов в каталоге.

Вам понадобится обратиться к man-страницам:

Также стоит посмотреть модуль std::ffi. Там вы найдёте несколько строковых типов, которые нужны для упражнения:

ТипыКодировкаИспользуется
str и StringUTF-8Обработка текста в Rust
CStr и CStringЗаканчивается NULВзаимодействие с функциями C
OsStr и OsStringЗависит от ОСВзаимодействие с ОС

Вам придётся преобразовывать между всеми этими типами:

  • &str в CString: нужно выделить место для завершающего символа \0,
  • CString в *const c_char: нужен указатель для вызова функций C,
  • *const c_char в &CStr: нужно нечто, что найдёт завершающий символ \0,
  • &CStr в &[u8]: срез байтов — универсальный интерфейс для «каких-то неизвестных данных»,
  • &[u8] в &OsStr: &OsStr — шаг к OsString, для его создания используйте OsStrExt,
  • &OsStr в OsString: нужно клонировать данные из &OsStr, чтобы вернуть их и снова вызвать readdir.

В Nomicon также есть очень полезная глава о FFI.

Скопируйте код ниже на https://play.rust-lang.org/ и заполните недостающие функции и методы:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

// TODO: удалить после реализации.
#![allow(unused_imports, unused_variables, dead_code)]

mod ffi {
    use std::os::raw::{c_char, c_int};
    #[cfg(not(target_os = "macos"))]
    use std::os::raw::{c_long, c_uchar, c_ulong, c_ushort};

    // Непрозрачный тип. См. https://doc.rust-lang.org/nomicon/ffi.html.
    #[repr(C)]
    pub struct DIR {
        _data: [u8; 0],
        _marker: core::marker::PhantomData<(*mut u8, core::marker::PhantomPinned)>,
    }

    // Раскладка по man-странице readdir(3) для Linux, где ino_t и
    // off_t определены согласно /usr/include/x86_64-linux-gnu/{sys/types.h, bits/typesizes.h}.
    #[cfg(not(target_os = "macos"))]
    #[repr(C)]
    pub struct dirent {
        pub d_ino: c_ulong,
        pub d_off: c_long,
        pub d_reclen: c_ushort,
        pub d_type: c_uchar,
        pub d_name: [c_char; 256],
    }

    // Раскладка по man-странице macOS для dir(5).
    #[cfg(target_os = "macos")]
    #[repr(C)]
    pub struct dirent {
        pub d_fileno: u64,
        pub d_seekoff: u64,
        pub d_reclen: u16,
        pub d_namlen: u16,
        pub d_type: u8,
        pub d_name: [c_char; 1024],
    }

    unsafe extern "C" {
        pub unsafe fn opendir(s: *const c_char) -> *mut DIR;

        #[cfg(not(all(target_os = "macos", target_arch = "x86_64")))]
        pub unsafe fn readdir(s: *mut DIR) -> *const dirent;

        // См. https://github.com/rust-lang/libc/issues/414 и раздел про
        // _DARWIN_FEATURE_64_BIT_INODE на man-странице stat(2) для macOS.
        //
        // «Платформы, существовавшие до появления этих обновлений» относится
        // к macOS (в отличие от iOS / wearOS и т. п.) на Intel и PowerPC.
        #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
        #[link_name = "readdir$INODE64"]
        pub unsafe fn readdir(s: *mut DIR) -> *const dirent;

        pub unsafe fn closedir(s: *mut DIR) -> c_int;
    }
}

use std::ffi::{CStr, CString, OsStr, OsString};
use std::os::unix::ffi::OsStrExt;

#[derive(Debug)]
struct DirectoryIterator {
    path: CString,
    dir: *mut ffi::DIR,
}

impl DirectoryIterator {
    fn new(path: &str) -> Result<DirectoryIterator, String> {
        // Вызовите opendir и верните значение Ok, если всё получилось,
        // иначе верните Err с сообщением.
        todo!()
    }
}

impl Iterator for DirectoryIterator {
    type Item = OsString;
    fn next(&mut self) -> Option<OsString> {
        // Продолжаем вызывать readdir, пока не получим нулевой указатель.
        todo!()
    }
}

impl Drop for DirectoryIterator {
    fn drop(&mut self) {
        // Вызывайте closedir по мере необходимости.
        todo!()
    }
}

fn main() -> Result<(), String> {
    let iter = DirectoryIterator::new(".")?;
    println!("files: {:#?}", iter.collect::<Vec<_>>());
    Ok(())
}
This slide and its sub-slides should take about 30 minutes.

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

Решение

Модульные тесты используют крейт tempfile. Добавьте его как dev-зависимость с помощью:

cargo add --dev tempfile
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

mod ffi {
    use std::os::raw::{c_char, c_int};
    #[cfg(not(target_os = "macos"))]
    use std::os::raw::{c_long, c_uchar, c_ulong, c_ushort};

    // Непрозрачный тип. См. https://doc.rust-lang.org/nomicon/ffi.html.
    #[repr(C)]
    pub struct DIR {
        _data: [u8; 0],
        _marker: core::marker::PhantomData<(*mut u8, core::marker::PhantomPinned)>,
    }

    // Раскладка по man-странице readdir(3) для Linux, где ino_t и
    // off_t определены согласно /usr/include/x86_64-linux-gnu/{sys/types.h, bits/typesizes.h}.
    #[cfg(not(target_os = "macos"))]
    #[repr(C)]
    pub struct dirent {
        pub d_ino: c_ulong,
        pub d_off: c_long,
        pub d_reclen: c_ushort,
        pub d_type: c_uchar,
        pub d_name: [c_char; 256],
    }

    // Раскладка по man-странице macOS для dir(5).
    #[cfg(target_os = "macos")]
    #[repr(C)]
    pub struct dirent {
        pub d_fileno: u64,
        pub d_seekoff: u64,
        pub d_reclen: u16,
        pub d_namlen: u16,
        pub d_type: u8,
        pub d_name: [c_char; 1024],
    }

    unsafe extern "C" {
        pub unsafe fn opendir(s: *const c_char) -> *mut DIR;

        #[cfg(not(all(target_os = "macos", target_arch = "x86_64")))]
        pub unsafe fn readdir(s: *mut DIR) -> *const dirent;

        // См. https://github.com/rust-lang/libc/issues/414 и раздел про
        // _DARWIN_FEATURE_64_BIT_INODE на man-странице stat(2) для macOS.
        //
        // «Платформы, существовавшие до появления этих обновлений» относится
        // к macOS (в отличие от iOS / wearOS и т. п.) на Intel и PowerPC.
        #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
        #[link_name = "readdir$INODE64"]
        pub unsafe fn readdir(s: *mut DIR) -> *const dirent;

        pub unsafe fn closedir(s: *mut DIR) -> c_int;
    }
}

use std::ffi::{CStr, CString, OsStr, OsString};
use std::os::unix::ffi::OsStrExt;

#[derive(Debug)]
struct DirectoryIterator {
    path: CString,
    dir: *mut ffi::DIR,
}

impl DirectoryIterator {
    fn new(path: &str) -> Result<DirectoryIterator, String> {
        // Вызовите opendir и верните значение Ok, если всё получилось,
        // иначе верните Err с сообщением.
        let path =
            CString::new(path).map_err(|err| format!("Invalid path: {err}"))?;
        // SAFETY: path.as_ptr() не может быть NULL.
        let dir = unsafe { ffi::opendir(path.as_ptr()) };
        if dir.is_null() {
            Err(format!("Could not open {path:?}"))
        } else {
            Ok(DirectoryIterator { path, dir })
        }
    }
}

impl Iterator for DirectoryIterator {
    type Item = OsString;
    fn next(&mut self) -> Option<OsString> {
        // Продолжаем вызывать readdir, пока не получим нулевой указатель.
        // SAFETY: self.dir никогда не бывает NULL.
        let dirent = unsafe { ffi::readdir(self.dir) };
        if dirent.is_null() {
            // Мы дошли до конца каталога.
            return None;
        }
        // SAFETY: dirent не NULL, а dirent.d_name завершается
        // NUL-символом.
        let d_name = unsafe { CStr::from_ptr((*dirent).d_name.as_ptr()) };
        let os_str = OsStr::from_bytes(d_name.to_bytes());
        Some(os_str.to_owned())
    }
}

impl Drop for DirectoryIterator {
    fn drop(&mut self) {
        // Вызывайте closedir по мере необходимости.
        // SAFETY: self.dir никогда не бывает NULL.
        if unsafe { ffi::closedir(self.dir) } != 0 {
            panic!("Could not close {:?}", self.path);
        }
    }
}

fn main() -> Result<(), String> {
    let iter = DirectoryIterator::new(".")?;
    println!("files: {:#?}", iter.collect::<Vec<_>>());
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::error::Error;

    #[test]
    fn test_nonexisting_directory() {
        let iter = DirectoryIterator::new("no-such-directory");
        assert!(iter.is_err());
    }

    #[test]
    fn test_empty_directory() -> Result<(), Box<dyn Error>> {
        let tmp = tempfile::TempDir::new()?;
        let iter = DirectoryIterator::new(
            tmp.path().to_str().ok_or("Non UTF-8 character in path")?,
        )?;
        let mut entries = iter.collect::<Vec<_>>();
        entries.sort();
        assert_eq!(entries, &[".", ".."]);
        Ok(())
    }

    #[test]
    fn test_nonempty_directory() -> Result<(), Box<dyn Error>> {
        let tmp = tempfile::TempDir::new()?;
        std::fs::write(tmp.path().join("foo.txt"), "The Foo Diaries\n")?;
        std::fs::write(tmp.path().join("bar.png"), "<PNG>\n")?;
        std::fs::write(tmp.path().join("crab.rs"), "//! Crab\n")?;
        let iter = DirectoryIterator::new(
            tmp.path().to_str().ok_or("Non UTF-8 character in path")?,
        )?;
        let mut entries = iter.collect::<Vec<_>>();
        entries.sort();
        assert_eq!(entries, &[".", "..", "bar.png", "crab.rs", "foo.txt"]);
        Ok(())
    }
}
  • Комментарии о безопасности: перед каждым блоком unsafe стоит комментарий // SAFETY:, объясняющий, почему операция безопасна. Это стандартная практика в Rust, которая помогает проводить аудит.
  • Преобразования строк: код демонстрирует преобразования, необходимые для FFI:
    • &str -> CString: создаём строку, завершающуюся нулём, для C.
    • CString -> *const c_char: передаём указатель в C.
    • *const c_char -> &CStr: оборачиваем возвращённую C-строку.
    • &CStr -> &[u8] -> &OsStr -> OsString: превращаем байты обратно в строку ОС Rust.
  • RAII (Drop): мы реализуем Drop, чтобы вызывать closedir автоматически, когда итератор выходит из области видимости. Так мы не допускаем утечки файловых дескрипторов.
  • Интерфейс итератора: мы оборачиваем C API в Iterator Rust, предоставляя безопасный и идиоматичный интерфейс (next возвращает Option<OsString>) к нижележащим небезопасным функциям C.
  • Объясните, что CString владеет данными (как String), а CStr — заимствованная ссылка (как &str).
  • Трейт OsStrExt нужен в Unix-системах, чтобы преобразовывать байты напрямую в OsStr.

Добро пожаловать в Rust в Android

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

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

Настройка

Для тестирования кода мы будем использовать виртуальное устройство Android Cuttlefish. Убедитесь, что у вас есть к нему доступ, или создайте новое с помощью:

source build/envsetup.sh
lunch aosp_cf_x86_64_phone-trunk_staging-userdebug
acloud create

Подробности см. в Android Developer Codelab.

Код на следующих страницах можно найти в каталоге src/android/ материалов курса. Чтобы следовать примерам, выполните git clone репозитория.

Ключевые моменты:

  • Cuttlefish — эталонное устройство Android, рассчитанное на работу на обычных рабочих станциях с Linux. Поддержка macOS также планируется.

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

Правила сборки

Система сборки Android (Soong) поддерживает Rust через несколько модулей:

Тип модуляОписание
rust_binaryСоздаёт бинарный файл Rust.
rust_libraryСоздаёт библиотеку Rust и предоставляет варианты rlib и dylib.
rust_ffiСоздаёт библиотеку C на Rust, которую могут использовать модули cc; предоставляет статический и динамический варианты.
rust_proc_macroСоздаёт библиотеку Rust типа proc-macro. Они аналогичны плагинам компилятора.
rust_testСоздаёт бинарный файл тестов Rust, который использует стандартную тестовую обвязку (test harness) Rust.
rust_fuzzСоздаёт бинарный файл Rust для фаззинга на основе libfuzzer.
rust_protobufГенерирует исходный код и создаёт библиотеку Rust, которая предоставляет интерфейс для конкретного protobuf.
rust_bindgenГенерирует исходный код и создаёт библиотеку Rust с привязками к библиотекам C.

Дальше мы рассмотрим rust_binary и rust_library.

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

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

  • Из соображений соответствия требованиям и производительности Android должен хранить крейты внутри дерева исходников. Кроме того, он должен взаимодействовать с кодом на C/C++/Java. Soong восполняет этот пробел.

  • Soong во многом похож на Bazel, который представляет собой открытый вариант Blaze (используемого в google3).

  • Забавный факт: Data из «Звёздного пути» — Android типа Soong.

Бинарные файлы Rust

Начнём с простого приложения. В корне копии AOSP создайте следующие файлы:

hello_rust/Android.bp:

rust_binary {
    name: "hello_rust",
    crate_name: "hello_rust",
    srcs: ["src/main.rs"],
}

hello_rust/src/main.rs:

// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

//! Демонстрация Rust.

/// Выводит приветствие в стандартный вывод.
fn main() {
    println!("Hello from Rust!");
}

Теперь вы можете собрать, загрузить и запустить бинарный файл:

m hello_rust
adb push "$ANDROID_PRODUCT_OUT/system/bin/hello_rust" /data/local/tmp
adb shell /data/local/tmp/hello_rust
Hello from Rust!
  • Пройдите шаги сборки и продемонстрируйте, как они работают в вашем эмуляторе.

  • Заметили обширные комментарии документации? Правила сборки Android требуют, чтобы у всех модулей была документация. Попробуйте удалить её и посмотрите, какую ошибку вы получите.

  • Подчеркните, что правила сборки Rust выглядят так же, как другие правила Soong. Это сделано намеренно, чтобы использовать Rust было так же просто, как C++ или Java.

Библиотеки Rust

Чтобы создать новую библиотеку Rust для Android, используйте rust_library.

Здесь мы объявляем зависимость от двух библиотек:

  • libgreeting, которую мы определим ниже,
  • libtextwrap, которая уже включена в дерево исходников как крейт в external/rust/android-crates-io/crates/.

hello_rust/Android.bp:

rust_binary {
    name: "hello_rust_with_dep",
    crate_name: "hello_rust_with_dep",
    srcs: ["src/main.rs"],
    rustlibs: [
        "libgreetings",
        "libtextwrap",
    ],
    prefer_rlib: true, // Нужно, чтобы избежать ошибки динамической линковки.
}

rust_library {
    name: "libgreetings",
    crate_name: "greetings",
    srcs: ["src/lib.rs"],
}

hello_rust/src/main.rs:

// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

//! Демонстрация Rust.

use greetings::greeting;
use textwrap::fill;

/// Выводит приветствие в стандартный вывод.
fn main() {
    println!("{}", fill(&greeting("Борис"), 24));
}

hello_rust/src/lib.rs:

// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

//! Библиотека приветствий.

/// Поприветствовать `name`.
pub fn greeting(name: &str) -> String {
    format!("Hello {name}, it is very nice to meet you!")
}

Собираем, загружаем и запускаем бинарный файл так же, как раньше:

m hello_rust_with_dep
adb push "$ANDROID_PRODUCT_OUT/system/bin/hello_rust_with_dep" /data/local/tmp
adb shell /data/local/tmp/hello_rust_with_dep
Hello Bob, it is very
nice to meet you!
  • Пройдите шаги сборки и продемонстрируйте, как они работают в вашем эмуляторе.

  • Крейт Rust с именем greetings должен собираться правилом libgreetings. Обратите внимание, как Rust-код использует имя крейта, как это принято в Rust.

  • Опять же, правила сборки требуют добавлять комментарии документации ко всем публичным элементам.

AIDL

Rust поддерживает Android Interface Definition Language (AIDL):

  • Код на Rust может вызывать существующие серверы AIDL.
  • Вы можете создавать новые серверы AIDL на Rust.
  • AIDL позволяет приложениям Android взаимодействовать друг с другом.

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

Учебный пример: сервис Birthday

Чтобы показать использование Rust с Binder, мы создадим интерфейс Binder. Затем реализуем сервис и напишем клиент, который с ним взаимодействует.

Интерфейсы AIDL

API вашего сервиса объявляется с помощью интерфейса AIDL:

birthday_service/aidl/com/example/birthdayservice/IBirthdayService.aidl:

package com.example.birthdayservice;

/** Интерфейс сервиса дня рождения. */
interface IBirthdayService {
    /** Сгенерировать поздравление с днём рождения. */
    String wishHappyBirthday(String name, int years);
}

birthday_service/aidl/Android.bp:

aidl_interface {
    name: "com.example.birthdayservice",
    srcs: ["com/example/birthdayservice/*.aidl"],
    unstable: true,
    backend: {
        rust: { // Rust по умолчанию не включён
            enabled: true,
        },
    },
}
  • Обратите внимание, что структура каталогов под aidl/ должна соответствовать имени пакета, используемому в файле AIDL: то есть пакет — com.example.birthdayservice, а файл лежит в aidl/com/example/IBirthdayService.aidl.

Сгенерированный API сервиса

Binder генерирует трейт для каждого определения интерфейса.

birthday_service/aidl/com/example/birthdayservice/IBirthdayService.aidl:

/** Интерфейс сервиса дня рождения. */
interface IBirthdayService {
    /** Сгенерировать поздравление с днём рождения. */
    String wishHappyBirthday(String name, int years);
}

out/soong/.intermediates/…/com_example_birthdayservice.rs:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

trait IBirthdayService {
    fn wishHappyBirthday(&self, name: &str, years: i32) -> binder::Result<String>;
}

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

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

Реализация сервиса

Теперь мы можем реализовать сервис AIDL:

birthday_service/src/lib.rs:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

//! Реализация интерфейса AIDL `IBirthdayService`.
use com_example_birthdayservice::aidl::com::example::birthdayservice::IBirthdayService::IBirthdayService;
use com_example_birthdayservice::binder;

/// Реализация `IBirthdayService`.
pub struct BirthdayService;

impl binder::Interface for BirthdayService {}

impl IBirthdayService for BirthdayService {
    fn wishHappyBirthday(&self, name: &str, years: i32) -> binder::Result<String> {
        Ok(format!("Happy Birthday {name}, congratulations with the {years} years!"))
    }
}

birthday_service/Android.bp:

rust_library {
    name: "libbirthdayservice",
    crate_name: "birthdayservice",
    srcs: ["src/lib.rs"],
    rustlibs: [
        "com.example.birthdayservice-rust",
    ],
}
  • Покажите путь к сгенерированному трейту IBirthdayService и объясните, почему нужен каждый из сегментов пути.
  • Обратите внимание, что wishHappyBirthday и другие методы IPC AIDL принимают &self (а не &mut self).
    • Это необходимо, потому что Binder обрабатывает входящие запросы в пуле потоков, что позволяет обрабатывать несколько запросов параллельно. Поэтому методы сервиса получают только разделяемую ссылку на self.
    • Любое состояние, которое сервис должен изменять, придётся поместить во что-то вроде Mutex, чтобы допустить безопасное изменение.
    • Правильный подход к управлению состоянием сервиса сильно зависит от деталей вашего сервиса.
  • TODO: Что делает трейт binder::Interface? Есть ли методы, которые можно переопределить? Где исходный код?

Сервер AIDL

Наконец, мы можем создать сервер, который открывает сервис:

birthday_service/src/server.rs:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

//! Сервис дня рождения.
use birthdayservice::BirthdayService;
use com_example_birthdayservice::aidl::com::example::birthdayservice::IBirthdayService::BnBirthdayService;
use com_example_birthdayservice::binder;

const SERVICE_IDENTIFIER: &str = "birthdayservice";

/// Точка входа сервиса дня рождения.
fn main() {
    let birthday_service = BirthdayService;
    let birthday_service_binder = BnBirthdayService::new_binder(
        birthday_service,
        binder::BinderFeatures::default(),
    );
    binder::add_service(SERVICE_IDENTIFIER, birthday_service_binder.as_binder())
        .expect("Failed to register service");
    binder::ProcessState::join_thread_pool();
}

birthday_service/Android.bp:

rust_binary {
    name: "birthday_server",
    crate_name: "birthday_server",
    srcs: ["src/server.rs"],
    rustlibs: [
        "com.example.birthdayservice-rust",
        "libbirthdayservice",
    ],
    prefer_rlib: true, // Чтобы избежать ошибки динамической линковки.
}

Процесс превращения пользовательской реализации сервиса (в данном случае типа BirthdayService, который реализует IBirthdayService) в сервис Binder состоит из нескольких шагов. Это может показаться сложнее, чем студенты привыкли, если они использовали Binder из C++ или другого языка. Объясните студентам, зачем нужен каждый шаг.

  1. Создайте экземпляр вашего типа сервиса (BirthdayService).
  2. Оберните объект сервиса в соответствующий тип Bn* (в данном случае BnBirthdayService). Этот тип генерируется Binder и предоставляет общую функциональность Binder, похожую на базовый класс BnBinder в C++. Поскольку в Rust нет наследования, мы используем композицию: помещаем наш BirthdayService внутрь сгенерированного BnBinderService.
  3. Вызовите add_service, передав ему идентификатор сервиса и объект сервиса (в примере — объект BnBirthdayService).
  4. Вызовите join_thread_pool, чтобы добавить текущий поток в пул потоков Binder и начать слушать подключения.

Развертывание

Теперь мы можем собрать, загрузить и запустить сервис:

m birthday_server
adb push "$ANDROID_PRODUCT_OUT/system/bin/birthday_server" /data/local/tmp
adb root
adb shell /data/local/tmp/birthday_server

В другом терминале проверьте, что сервис работает:

adb shell service check birthdayservice
Service birthdayservice: found

Вы также можете вызвать сервис с помощью service call:

adb shell service call birthdayservice 1 s16 Bob i32 24
Result: Parcel(
  0x00000000: 00000000 00000036 00610048 00700070 '....6...H.a.p.p.'
  0x00000010: 00200079 00690042 00740072 00640068 'y. .B.i.r.t.h.d.'
  0x00000020: 00790061 00420020 0062006f 0020002c 'a.y. .B.o.b.,. .'
  0x00000030: 006f0063 0067006e 00610072 00750074 'c.o.n.g.r.a.t.u.'
  0x00000040: 0061006c 00690074 006e006f 00200073 'l.a.t.i.o.n.s. .'
  0x00000050: 00690077 00680074 00740020 00650068 'w.i.t.h. .t.h.e.'
  0x00000060: 00320020 00200034 00650079 00720061 ' .2.4. .y.e.a.r.'
  0x00000070: 00210073 00000000                   's.!.....        ')

Клиент AIDL

Наконец, мы можем создать клиент Rust для нашего нового сервиса.

birthday_service/src/client.rs:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use com_example_birthdayservice::aidl::com::example::birthdayservice::IBirthdayService::IBirthdayService;
use com_example_birthdayservice::binder;

const SERVICE_IDENTIFIER: &str = "birthdayservice";

/// Вызвать сервис дня рождения.
fn main() -> Result<(), Box<dyn Error>> {
    let name = std::env::args().nth(1).unwrap_or_else(|| String::from("Борис"));
    let years = std::env::args()
        .nth(2)
        .and_then(|arg| arg.parse::<i32>().ok())
        .unwrap_or(42);

    binder::ProcessState::start_thread_pool();
    let service = binder::get_interface::<dyn IBirthdayService>(SERVICE_IDENTIFIER)
        .map_err(|_| "Failed to connect to BirthdayService")?;

    // Вызвать сервис.
    let msg = service.wishHappyBirthday(&name, years)?;
    println!("{msg}");
}

birthday_service/Android.bp:

rust_binary {
    name: "birthday_client",
    crate_name: "birthday_client",
    srcs: ["src/client.rs"],
    rustlibs: [
        "com.example.birthdayservice-rust",
    ],
    prefer_rlib: true, // Чтобы избежать ошибки динамической линковки.
}

Обратите внимание, что клиент не зависит от libbirthdayservice.

Соберите, загрузите и запустите клиент на устройстве:

m birthday_client
adb push "$ANDROID_PRODUCT_OUT/system/bin/birthday_client" /data/local/tmp
adb shell /data/local/tmp/birthday_client Charlie 60
Happy Birthday Charlie, congratulations with the 60 years!
  • Strong<dyn IBirthdayService> — объект трейта, который представляет сервис, к которому подключился клиент.
    • Strong — пользовательский умный указатель для Binder. Он отвечает и за счётчик ссылок объекта трейта внутри процесса, и за глобальный счётчик ссылок Binder, который отслеживает, сколько процессов имеют ссылку на объект.
    • Обратите внимание, что объект трейта, который клиент использует для общения с сервисом, использует тот же самый трейт, который реализует сервер. Для данного интерфейса Binder генерируется один трейт Rust, который используют и клиент, и сервер.
  • Используйте тот же идентификатор сервиса, что и при регистрации сервиса. В идеале он должен быть определён в общем крейте, от которого зависят и клиент, и сервер.

Изменение API

Расширим API: позволим клиентам передавать список строк для поздравительной открытки:

package com.example.birthdayservice;

/** Интерфейс сервиса дня рождения. */
interface IBirthdayService {
    /** Сгенерировать поздравление с днём рождения. */
    String wishHappyBirthday(String name, int years, in String[] text);
}

Это приводит к обновлённому определению трейта IBirthdayService:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

trait IBirthdayService {
    fn wishHappyBirthday(
        &self,
        name: &str,
        years: i32,
        text: &[String],
    ) -> binder::Result<String>;
}
  • Обратите внимание, как String[] в определении AIDL переводится в &[String] в Rust, то есть в сгенерированных привязках везде, где возможно, используются идиоматичные типы Rust:
    • Входные массивы (in) переводятся в срезы.
    • Аргументы out и inout переводятся в &mut Vec<T>.
    • Возвращаемые значения переводятся в возврат Vec<T>.

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

Обновите код клиента и сервера с учётом нового API.

birthday_service/src/lib.rs:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

impl IBirthdayService for BirthdayService {
    fn wishHappyBirthday(
        &self,
        name: &str,
        years: i32,
        text: &[String],
    ) -> binder::Result<String> {
        let mut msg = format!(
            "Happy Birthday {name}, congratulations with the {years} years!",
        );

        for line in text {
            msg.push('\n');
            msg.push_str(line);
        }

        Ok(msg)
    }
}

birthday_service/src/client.rs:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

let msg = service.wishHappyBirthday(
    &name,
    years,
    &[
        String::from("Habby birfday to yuuuuu"),
        String::from("And also: many more"),
    ],
)?;
  • TODO: Перенести фрагменты кода в файлы проекта, где они действительно будут собираться?

Работа с типами AIDL

Типы AIDL переводятся в соответствующие идиоматичные типы Rust:

  • Примитивные типы (в основном) соответствуют идиоматичным типам Rust.
  • Поддерживаются коллекционные типы, такие как срезы, Vec и строковые типы.
  • Ссылки на объекты AIDL и дескрипторы файлов можно передавать между клиентами и сервисами.
  • Дескрипторы файлов и parcelable полностью поддерживаются.

Примитивные типы

Примитивные типы (в основном) переводятся идиоматично:

AIDLRustПримечание
booleanbool
bytei8Обратите внимание, что байты знаковые.
charu16Обратите внимание на использование u16, а НЕ u32.
inti32
longi64
floatf32
doublef64
StringString

Массивы

Типы массивов (T[], byte[] и List<T>) переводятся в соответствующий тип массива Rust в зависимости от того, как они используются в сигнатуре функции:

ПозицияRust
Аргумент in&[T]
Аргумент out/inout&mut Vec<T>
возвращаемое значениеVec<T>
  • В Android 13 и выше поддерживаются массивы фиксированного размера, то есть T[N] становится [T; N]. Массивы фиксированного размера могут быть многомерными (например, int[3][4]). В бэкенде Java массивы фиксированного размера представлены как типы массивов.
  • Массивы в полях parcelable всегда переводятся в Vec<T>.

Передача объектов

Объекты AIDL можно передавать либо как конкретный тип AIDL, либо как интерфейс IBinder со стёртым типом:

birthday_service/aidl/com/example/birthdayservice/IBirthdayInfoProvider.aidl:

package com.example.birthdayservice;

interface IBirthdayInfoProvider {
    String name();
    int years();
}

birthday_service/aidl/com/example/birthdayservice/IBirthdayService.aidl:

import com.example.birthdayservice.IBirthdayInfoProvider;

interface IBirthdayService {
    /** То же самое, но с использованием объекта binder. */
    String wishWithProvider(IBirthdayInfoProvider provider);

    /** То же самое, но с использованием `IBinder`. */
    String wishWithErasedProvider(IBinder provider);
}

birthday_service/src/client.rs:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Структура Rust, реализующая интерфейс `IBirthdayInfoProvider`.
struct InfoProvider {
    name: String,
    age: u8,
}

impl binder::Interface for InfoProvider {}

impl IBirthdayInfoProvider for InfoProvider {
    fn name(&self) -> binder::Result<String> {
        Ok(self.name.clone())
    }

    fn years(&self) -> binder::Result<i32> {
        Ok(self.age as i32)
    }
}

fn main() {
    binder::ProcessState::start_thread_pool();
    let service = connect().expect("Failed to connect to BirthdayService");

    // Создаём объект binder для интерфейса `IBirthdayInfoProvider`.
    let provider = BnBirthdayInfoProvider::new_binder(
        InfoProvider { name: name.clone(), age: years as u8 },
        BinderFeatures::default(),
    );

    // Отправляем объект binder сервису.
    service.wishWithProvider(&provider)?;

    // Выполняем ту же операцию, но передаём провайдера как `SpIBinder`.
    service.wishWithErasedProvider(&provider.as_binder())?;
}
  • Обратите внимание на использование BnBirthdayInfoProvider. Он выполняет ту же роль, что и BnBirthdayService, который мы видели ранее.

Parcelable

Binder для Rust поддерживает передачу parcelable напрямую:

birthday_service/aidl/com/example/birthdayservice/BirthdayInfo.aidl:

package com.example.birthdayservice;

parcelable BirthdayInfo {
    String name;
    int years;
}

birthday_service/aidl/com/example/birthdayservice/IBirthdayService.aidl:

import com.example.birthdayservice.BirthdayInfo;

interface IBirthdayService {
    /** То же самое, но с parcelable. */
    String wishWithInfo(in BirthdayInfo info);
}

birthday_service/src/client.rs:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    binder::ProcessState::start_thread_pool();
    let service = connect().expect("Failed to connect to BirthdayService");

    let info = BirthdayInfo { name: "Алиса".into(), years: 123 };
    service.wishWithInfo(&info)?;
}

Передача файлов

Файлы можно передавать между клиентами и серверами Binder с помощью типа ParcelFileDescriptor:

birthday_service/aidl/com/example/birthdayservice/IBirthdayService.aidl:

interface IBirthdayService {
    /** То же самое, но загружает информацию из файла. */
    String wishFromFile(in ParcelFileDescriptor infoFile);
}

birthday_service/src/client.rs:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    binder::ProcessState::start_thread_pool();
    let service = connect().expect("Failed to connect to BirthdayService");

    // Откройте файл и запишите в него информацию о дне рождения.
    let mut file = File::create("/data/local/tmp/birthday.info").unwrap();
    writeln!(file, "{name}")?;
    writeln!(file, "{years}")?;

    // Создайте `ParcelFileDescriptor` из файла и отправьте его.
    let file = ParcelFileDescriptor::new(file);
    service.wishFromFile(&file)?;
}

birthday_service/src/lib.rs:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

impl IBirthdayService for BirthdayService {
    fn wishFromFile(
        &self,
        info_file: &ParcelFileDescriptor,
    ) -> binder::Result<String> {
        // Преобразуйте дескриптор файла в `File`. `ParcelFileDescriptor` оборачивает
        // `OwnedFd`, который можно клонировать, а затем использовать для создания
        // объекта `File`.
        let mut info_file = info_file
            .as_ref()
            .try_clone()
            .map(File::from)
            .expect("Invalid file handle");

        let mut contents = String::new();
        info_file.read_to_string(&mut contents).unwrap();

        let mut lines = contents.lines();
        let name = lines.next().unwrap();
        let years: i32 = lines.next().unwrap().parse().unwrap();

        Ok(format!("Happy Birthday {name}, congratulations with the {years} years!"))
    }
}
  • ParcelFileDescriptor оборачивает OwnedFd, поэтому его можно создать из File (или любого другого типа, который оборачивает OwnedFd), а на другой стороне использовать для создания нового дескриптора File.
  • Можно обернуть и отправить и другие типы файловых дескрипторов, например сокеты TCP, UDP и UNIX.

Тестирование в Android

Продолжая тему Тестирования, теперь посмотрим, как работают модульные тесты в AOSP. Для модульных тестов используйте модуль rust_test:

testing/Android.bp:

rust_library {
    name: "libleftpad",
    crate_name: "leftpad",
    srcs: ["src/lib.rs"],
}

rust_test {
    name: "libleftpad_test",
    crate_name: "leftpad_test",
    srcs: ["src/lib.rs"],
    host_supported: true,
    test_suites: ["general-tests"],
}

rust_test {
    name: "libgoogletest_example",
    crate_name: "googletest_example",
    srcs: ["googletest.rs"],
    rustlibs: ["libgoogletest_rust"],
    host_supported: true,
}

rust_test {
    name: "libmockall_example",
    crate_name: "mockall_example",
    srcs: ["mockall.rs"],
    rustlibs: ["libmockall"],
    host_supported: true,
}

testing/src/lib.rs:

#![allow(unused)]
fn main() {
// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

//! Библиотека для дополнения строк слева.

/// Дополняет `s` слева до `width`.
pub fn leftpad(s: &str, width: usize) -> String {
    format!("{s:>width$}")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn short_string() {
        assert_eq!(leftpad("foo", 5), "  foo");
    }

    #[test]
    fn long_string() {
        assert_eq!(leftpad("foobar", 6), "foobar");
    }
}
}

Теперь вы можете запустить тест с помощью

atest --host libleftpad_test

Вывод выглядит так:

INFO: Elapsed time: 2.666s, Critical Path: 2.40s
INFO: 3 processes: 2 internal, 1 linux-sandbox.
INFO: Build completed successfully, 3 total actions
//comprehensive-rust-android/testing:libleftpad_test_host            PASSED in 2.3s
    PASSED  libleftpad_test.tests::long_string (0.0s)
    PASSED  libleftpad_test.tests::short_string (0.0s)
Test cases: finished with 2 passing and 0 failing out of 2 test cases

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

GoogleTest

Крейт GoogleTest позволяет гибко писать проверки в тестах с помощью матчеров:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use googletest::prelude::*;

#[googletest::test]
fn test_elements_are() {
    let value = vec!["foo", "bar", "baz"];
    expect_that!(value, elements_are!(eq(&"foo"), lt(&"xyz"), starts_with("b")));
}

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

---- test_elements_are stdout ----
Value of: value
Expected: has elements:
  0. is equal to "foo"
  1. is less than "xyz"
  2. starts with prefix "!"
Actual: ["foo", "bar", "baz"],
  where element #2 is "baz", which does not start with "!"
  at src/testing/googletest.rs:6:5
Error: See failure output above
This slide should take about 5 minutes.
  • GoogleTest не входит в Rust Playground, поэтому этот пример нужно запускать в локальной среде. Чтобы быстро добавить его в существующий проект Cargo, используйте cargo add googletest.

  • Строка use googletest::prelude::*; импортирует ряд часто используемых макросов и типов.

  • Это лишь верхушка айсберга: встроенных матчеров много. Стоит пройти первую главу “Advanced testing for Rust applications”, самостоятельного курса по Rust: в ней даётся пошаговое введение в библиотеку, с упражнениями, которые помогут освоиться с макросами googletest, её матчерами и общей философией.

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

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[test]
fn test_multiline_string_diff() {
    let haiku = "Memory safety found,\n\
                 Rust's strong typing guides the way,\n\
                 Secure code you'll write.";
    assert_that!(
        haiku,
        eq("Memory safety found,\n\
            Rust's silly humor guides the way,\n\
            Secure code you'll write.")
    );
}

показывает цветной diff (цвета здесь не показаны):

    Value of: haiku
Expected: is equal to "Memory safety found,\nRust's silly humor guides the way,\nSecure code you'll write."
Actual: "Memory safety found,\nRust's strong typing guides the way,\nSecure code you'll write.",
  which isn't equal to "Memory safety found,\nRust's silly humor guides the way,\nSecure code you'll write."
Difference(-actual / +expected):
 Memory safety found,
-Rust's strong typing guides the way,
+Rust's silly humor guides the way,
 Secure code you'll write.
  at src/testing/googletest.rs:17:5

Моки

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

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::time::Duration;

#[mockall::automock]
pub trait Pet {
    fn is_hungry(&self, since_last_meal: Duration) -> bool;
}

#[test]
fn test_robot_dog() {
    let mut mock_dog = MockPet::new();
    mock_dog.expect_is_hungry().return_const(true);
    assert!(mock_dog.is_hungry(Duration::from_secs(10)));
}
This slide should take about 5 minutes.
  • Mockall — рекомендуемая библиотека для моков в Android (AOSP). На crates.io есть и другие библиотеки для моков, в частности для HTTP-сервисов. Они работают примерно так же, как Mockall, то есть позволяют легко получить мок-реализацию заданного трейта.

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

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

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

  • Mockall не входит в Rust Playground, поэтому этот пример нужно запускать в локальной среде. Чтобы быстро добавить Mockall в существующий проект Cargo, используйте cargo add mockall.

  • У Mockall обширная функциональность. В частности, можно задавать ожидания, которые зависят от переданных аргументов. Здесь мы используем это, чтобы замокать кота, который проголодается через 3 часа после последнего кормления:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[test]
fn test_robot_cat() {
    let mut mock_cat = MockPet::new();
    mock_cat
        .expect_is_hungry()
        .with(mockall::predicate::gt(Duration::from_secs(3 * 3600)))
        .return_const(true);
    mock_cat.expect_is_hungry().return_const(false);
    assert!(mock_cat.is_hungry(Duration::from_secs(5 * 3600)));
    assert!(!mock_cat.is_hungry(Duration::from_secs(5)));
}
  • Вы можете использовать .times(n), чтобы ограничить число вызовов метода мока значением n — если это условие не выполнено, мок автоматически вызовет панику при освобождении.

Логирование

Чтобы автоматически писать логи в logcat (на устройстве) или в stdout (на хосте), используйте крейт log:

hello_rust_logs/Android.bp:

rust_binary {
    name: "hello_rust_logs",
    crate_name: "hello_rust_logs",
    srcs: ["src/main.rs"],
    rustlibs: [
        "liblog_rust",
        "liblogger",
    ],
    host_supported: true,
}

hello_rust_logs/src/main.rs:

// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

//! Демонстрация логирования на Rust.

use log::{debug, error, info};

/// Записывает приветствие в лог.
fn main() {
    logger::init(
        logger::Config::default()
            .with_tag_on_device("rust")
            .with_max_level(log::LevelFilter::Trace),
    );
    debug!("Starting program.");
    info!("Things are going fine.");
    error!("Something went wrong!");
}

Соберите, загрузите и запустите бинарный файл на устройстве:

m hello_rust_logs
adb push "$ANDROID_PRODUCT_OUT/system/bin/hello_rust_logs" /data/local/tmp
adb shell /data/local/tmp/hello_rust_logs

Логи появятся в adb logcat:

adb logcat -s rust
09-08 08:38:32.454  2420  2420 D rust: hello_rust_logs: Starting program.
09-08 08:38:32.454  2420  2420 I rust: hello_rust_logs: Things are going fine.
09-08 08:38:32.454  2420  2420 E rust: hello_rust_logs: Something went wrong!
  • Реализация логгера в liblogger нужна только в итоговом бинарном файле. Если вы логируете из библиотеки, нужен только крейт-фасад log.

Взаимодействие

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

  • Вызывать функции Rust из других языков.
  • Вызывать из Rust функции, написанные на других языках.

Когда вы вызываете функции на другом языке, вы используете интерфейс внешних функций, также известный как FFI.

  • Это ключевая возможность Rust: скомпилированный код становится неотличим от скомпилированного кода C или C++.

  • Технически можно сказать, что Rust может компилироваться в тот же ABI (двоичный интерфейс приложений), что и код на C.

Взаимодействие с C

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

Если хотите, можно сделать это вручную:

// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

unsafe extern "C" {
    safe fn abs(x: i32) -> i32;
}

fn main() {
    let x = -42;
    let abs_x = abs(x);
    println!("{x}, {abs_x}");
}

Мы уже видели это в упражнении «Безопасная обёртка FFI».

Это предполагает полное знание целевой платформы. Не рекомендуется для рабочего кода.

Лучшие варианты мы рассмотрим дальше.

  • Часть "C" блока extern сообщает Rust, что abs можно вызывать, используя ABI (двоичный интерфейс приложений) C.

  • Часть safe fn abs сообщает Rust, что abs — безопасная функция. По умолчанию внешние функции небезопасны, но поскольку abs(x) не может вызвать неопределённое поведение ни при каком x, мы можем объявить её безопасной.

Простая библиотека на C

Сначала создадим небольшую библиотеку на C:

interoperability/bindgen/libbirthday.h:

typedef struct card {
  const char* name;
  int years;
} card;

void print_card(const card* card);

interoperability/bindgen/libbirthday.c:

#include <stdio.h>
#include "libbirthday.h"

void print_card(const card* card) {
  printf("+--------------\n");
  printf("| Happy Birthday %s!\n", card->name);
  printf("| Congratulations with the %i years!\n", card->years);
  printf("+--------------\n");
}

Добавьте это в ваш файл Android.bp:

interoperability/bindgen/Android.bp:

cc_library {
    name: "libbirthday",
    srcs: ["libbirthday.c"],
}

Использование Bindgen

Инструмент bindgen может автоматически генерировать привязки из заголовочного файла C.

Создайте обёрточный заголовочный файл для библиотеки (в этом примере он строго не нужен):

interoperability/bindgen/libbirthday_wrapper.h:

#include "libbirthday.h"

interoperability/bindgen/Android.bp:

rust_bindgen {
    name: "libbirthday_bindgen",
    crate_name: "birthday_bindgen",
    wrapper_src: "libbirthday_wrapper.h",
    source_stem: "bindings",
    static_libs: ["libbirthday"],
}

Наконец, мы можем использовать привязки в нашей программе на Rust:

interoperability/bindgen/Android.bp:

rust_binary {
    name: "print_birthday_card",
    srcs: ["main.rs"],
    rustlibs: ["libbirthday_bindgen"],
    static_libs: ["libbirthday"],
}

interoperability/bindgen/main.rs:

// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

//! Демонстрация bindgen.

use birthday_bindgen::{card, print_card};

fn main() {
    let name = std::ffi::CString::new("Peter").unwrap();
    let card = card { name: name.as_ptr(), years: 42 };
    // SAFETY: Передаваемый нами указатель допустим, потому что он получен из ссылки Rust,
    // а `name`, который он содержит, указывает на `name` выше, который также
    // остаётся допустимым. `print_card` не сохраняет ни один из указателей, чтобы использовать их позже,
    // после возврата.
    unsafe {
        print_card(&card);
    }
}
  • Правила сборки Android автоматически запустят bindgen за кулисами.

  • Обратите внимание, что Rust-код в main всё равно сложно писать. Хорошая практика — инкапсулировать вывод bindgen в библиотеку Rust, которая предоставляет вызывающему коду безопасный интерфейс.

Запуск нашего бинарного файла

Соберите, загрузите и запустите бинарный файл на устройстве:

m print_birthday_card
adb push "$ANDROID_PRODUCT_OUT/system/bin/print_birthday_card" /data/local/tmp
adb shell /data/local/tmp/print_birthday_card

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

interoperability/bindgen/Android.bp:

rust_test {
    name: "libbirthday_bindgen_test",
    srcs: [":libbirthday_bindgen"],
    crate_name: "libbirthday_bindgen_test",
    test_suites: ["general-tests"],
    auto_gen_config: true,
    clippy_lints: "none", // Автоматически сгенерированный файл, линтинг пропускаем
    lints: "none",
}
atest libbirthday_bindgen_test

Простая библиотека на Rust

Экспорт функций и типов Rust в C несложен. Вот простая библиотека Rust:

interoperability/rust/libanalyze/analyze.rs

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

//! Демонстрация FFI Rust.
#![deny(improper_ctypes_definitions)]

use std::os::raw::c_int;

/// Анализирует числа.
// SAFETY: Нет другой глобальной функции с таким именем.
#[unsafe(no_mangle)]
pub extern "C" fn analyze_numbers(x: c_int, y: c_int) {
    if x < y {
        println!("x ({x}) is smallest!");
    } else {
        println!("y ({y}) is probably larger than x ({x})");
    }
}

interoperability/rust/libanalyze/Android.bp

rust_ffi {
    name: "libanalyze_ffi",
    crate_name: "analyze_ffi",
    srcs: ["analyze.rs"],
    include_dirs: ["."],
}

#[unsafe(no_mangle)] отключает обычное искажение имён (name mangling) в Rust, поэтому экспортируемый символ будет просто именем функции. Также можно использовать #[unsafe(export_name = "some_name")], чтобы задать любое нужное имя.

Вызов Rust

Теперь можно вызвать это из бинарного файла на C:

interoperability/rust/libanalyze/analyze.h

#ifndef ANALYZE_H
#define ANALYZE_H

void analyze_numbers(int x, int y);

#endif

interoperability/rust/analyze/main.c

#include "analyze.h"

int main() {
  analyze_numbers(10, 20);
  analyze_numbers(123, 123);
  return 0;
}

interoperability/rust/analyze/Android.bp

cc_binary {
    name: "analyze_numbers",
    srcs: ["main.c"],
    static_libs: ["libanalyze_ffi"],
}

Соберите, загрузите и запустите бинарный файл на устройстве:

m analyze_numbers
adb push "$ANDROID_PRODUCT_OUT/system/bin/analyze_numbers" /data/local/tmp
adb shell /data/local/tmp/analyze_numbers

С языком C++

Крейт CXX обеспечивает безопасное взаимодействие между Rust и C++.

Общий подход выглядит так:

Модуль bridge

CXX опирается на описание сигнатур функций, которые будут открыты с каждой стороны для другой. Вы задаёте это описание с помощью блоков extern в модуле Rust, помеченном атрибутным макросом #[cxx::bridge].

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[allow(unsafe_op_in_unsafe_fn)]
#[cxx::bridge(namespace = "org::blobstore")]
mod ffi {
    // Общие структуры с полями, видимыми для обоих языков.
    struct BlobMetadata {
        size: usize,
        tags: Vec<String>,
    }

    // Типы и сигнатуры Rust, открытые для C++.
    extern "Rust" {
        type MultiBuf;

        fn next_chunk(buf: &mut MultiBuf) -> &[u8];
    }

    // Типы и сигнатуры C++, открытые для Rust.
    unsafe extern "C++" {
        include!("include/blobstore.h");

        type BlobstoreClient;

        fn new_blobstore_client() -> UniquePtr<BlobstoreClient>;
        fn put(self: Pin<&mut BlobstoreClient>, parts: &mut MultiBuf) -> u64;
        fn tag(self: Pin<&mut BlobstoreClient>, blobid: u64, tag: &str);
        fn metadata(&self, blobid: u64) -> BlobMetadata;
    }
}
  • Мост обычно объявляется в модуле ffi внутри вашего крейта.
  • Из объявлений в модуле моста CXX сгенерирует соответствующие определения типов и функций на Rust и C++, чтобы открыть эти элементы обоим языкам.
  • Чтобы посмотреть сгенерированный код Rust, используйте cargo-expand, чтобы увидеть раскрытый процедурный макрос. Для большинства примеров можно использовать cargo expand ::ffi, чтобы раскрыть только модуль ffi (однако это не относится к проектам Android).
  • Чтобы посмотреть сгенерированный код C++, загляните в target/cxxbridge.

Объявления моста Rust

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[cxx::bridge]
mod ffi {
    extern "Rust" {
        type MyType; // Непрозрачный тип
        fn foo(&self); // Метод `MyType`
        fn bar() -> Box<MyType>; // Свободная функция
    }
}

struct MyType(i32);

impl MyType {
    fn foo(&self) {
        println!("{}", self.0);
    }
}

fn bar() -> Box<MyType> {
    Box::new(MyType(123))
}
  • Элементы, объявленные в extern "Rust", ссылаются на элементы, которые находятся в области видимости родительского модуля.
  • Генератор кода CXX использует ваши секции extern "Rust", чтобы создать заголовочный файл C++ с соответствующими объявлениями на C++. Сгенерированный заголовок имеет тот же путь, что и исходный файл Rust с мостом, только с расширением .rs.h.

Сгенерированный C++

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[cxx::bridge]
mod ffi {
    // Типы и сигнатуры Rust, открытые для C++.
    extern "Rust" {
        type MultiBuf;

        fn next_chunk(buf: &mut MultiBuf) -> &[u8];
    }
}

Получается (примерно) такой C++:

struct MultiBuf final : public ::rust::Opaque {
  ~MultiBuf() = delete;

private:
  friend ::rust::layout;
  struct layout {
    static ::std::size_t size() noexcept;
    static ::std::size_t align() noexcept;
  };
};

::rust::Slice<::std::uint8_t const> next_chunk(::org::blobstore::MultiBuf &buf) noexcept;

Объявления моста C++

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[cxx::bridge]
mod ffi {
    // Типы и сигнатуры C++, открытые для Rust.
    unsafe extern "C++" {
        include!("include/blobstore.h");

        type BlobstoreClient;

        fn new_blobstore_client() -> UniquePtr<BlobstoreClient>;
        fn put(self: Pin<&mut BlobstoreClient>, parts: &mut MultiBuf) -> u64;
        fn tag(self: Pin<&mut BlobstoreClient>, blobid: u64, tag: &str);
        fn metadata(&self, blobid: u64) -> BlobMetadata;
    }
}

Получается (примерно) такой Rust:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[repr(C)]
pub struct BlobstoreClient {
    _private: ::cxx::private::Opaque,
}

pub fn new_blobstore_client() -> ::cxx::UniquePtr<BlobstoreClient> {
    extern "C" {
        #[link_name = "org$blobstore$cxxbridge1$new_blobstore_client"]
        fn __new_blobstore_client() -> *mut BlobstoreClient;
    }
    unsafe { ::cxx::UniquePtr::from_raw(__new_blobstore_client()) }
}

impl BlobstoreClient {
    pub fn put(&self, parts: &mut MultiBuf) -> u64 {
        extern "C" {
            #[link_name = "org$blobstore$cxxbridge1$BlobstoreClient$put"]
            fn __put(
                _: &BlobstoreClient,
                parts: *mut ::cxx::core::ffi::c_void,
            ) -> u64;
        }
        unsafe {
            __put(self, parts as *mut MultiBuf as *mut ::cxx::core::ffi::c_void)
        }
    }
}

// ...
  • Программисту не нужно ручаться, что введённые им сигнатуры точны. CXX выполняет статические проверки (static assertions), что сигнатуры точно соответствуют объявлению на C++.
  • Блоки unsafe extern позволяют объявлять функции C++, которые безопасно вызывать из Rust.

Общие типы

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[cxx::bridge]
mod ffi {
    #[derive(Clone, Debug, Hash)]
    struct PlayingCard {
        suit: Suit,
        value: u8,  // A=1, J=11, Q=12, K=13
    }

    enum Suit {
        Clubs,
        Diamonds,
        Hearts,
        Spades,
    }
}
  • Поддерживаются только перечисления в стиле C (без данных).
  • Для #[derive()] на общих типах поддерживается ограниченный набор трейтов. Соответствующая функциональность также генерируется для кода C++: например, если вы выведете Hash, будет сгенерирована и реализация std::hash для соответствующего типа C++.

Общие перечисления

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[cxx::bridge]
mod ffi {
    enum Suit {
        Clubs,
        Diamonds,
        Hearts,
        Spades,
    }
}

Сгенерированный Rust:

#![allow(unused)]
fn main() {
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Copy, Clone, PartialEq, Eq)]
#[repr(transparent)]
pub struct Suit {
    pub repr: u8,
}

#[allow(non_upper_case_globals)]
impl Suit {
    pub const Clubs: Self = Suit { repr: 0 };
    pub const Diamonds: Self = Suit { repr: 1 };
    pub const Hearts: Self = Suit { repr: 2 };
    pub const Spades: Self = Suit { repr: 3 };
}
}

Сгенерированный C++:

enum class Suit : uint8_t {
  Clubs = 0,
  Diamonds = 1,
  Hearts = 2,
  Spades = 3,
};
  • На стороне Rust код, сгенерированный для общих перечислений, на самом деле представляет собой структуру, оборачивающую числовое значение. Это потому, что в C++ не является UB, когда enum class содержит значение, отличное от всех перечисленных вариантов, и наше представление в Rust должно вести себя так же.

Обработка ошибок Rust

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[cxx::bridge]
mod ffi {
    extern "Rust" {
        fn fallible(depth: usize) -> Result<String>;
    }
}

fn fallible(depth: usize) -> anyhow::Result<String> {
    if depth == 0 {
        return Err(anyhow::Error::msg("fallible1 requires depth > 0"));
    }

    Ok("Success!".into())
}
  • Функции Rust, которые возвращают Result, на стороне C++ превращаются в исключения.
  • Выбрасываемое исключение всегда имеет тип rust::Error, который в первую очередь предоставляет способ получить строку сообщения об ошибке. Сообщение берётся из реализации Display типа ошибки.
  • Паника, раскручивающаяся из Rust в C++, всегда приводит к немедленному завершению процесса.

Обработка ошибок C++

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[cxx::bridge]
mod ffi {
    unsafe extern "C++" {
        include!("example/include/example.h");
        fn fallible(depth: usize) -> Result<String>;
    }
}

fn main() {
    if let Err(err) = ffi::fallible(99) {
        eprintln!("Error: {}", err);
        process::exit(1);
    }
}
  • Функции C++, объявленные как возвращающие Result, перехватывают любое исключение на стороне C++ и возвращают его вызывающей функции Rust как значение Err.
  • Если исключение выброшено из функции extern "C++", которая не объявлена мостом CXX как возвращающая Result, программа вызывает std::terminate в C++. Поведение эквивалентно тому, как если бы исключение вылетело из функции C++ с noexcept.

Дополнительные типы

RustC++
Stringrust::String
&strrust::Str
CxxStringstd::string
&[T]/&mut [T]rust::Slice
Box<T>rust::Box<T>
UniquePtr<T>std::unique_ptr<T>
Vec<T>rust::Vec<T>
CxxVector<T>std::vector<T>
  • Эти типы можно использовать в полях общих структур, а также в аргументах и возвращаемых значениях внешних функций.
  • Обратите внимание, что String в Rust напрямую не соответствует std::string. Этому есть несколько причин:
    • std::string не поддерживает инвариант UTF-8, который требует String.
    • У этих типов разная раскладка в памяти, поэтому их нельзя напрямую передавать между языками.
    • std::string требует конструкторов перемещения, которые не совпадают с семантикой перемещения Rust, поэтому std::string нельзя передать в Rust по значению.

Сборка в Android

Создайте два genrule: один для генерации заголовка CXX, другой для генерации исходного файла CXX. Затем они используются как входные данные для cc_library_static.

// Сгенерируйте заголовок C++ с привязками C++
// к экспортируемым функциям Rust в lib.rs.
genrule {
    name: "libcxx_test_bridge_header",
    tools: ["cxxbridge"],
    cmd: "$(location cxxbridge) $(in) --header > $(out)",
    srcs: ["lib.rs"],
    out: ["lib.rs.h"],
}

// Сгенерируйте код C++, который вызывает Rust.
genrule {
    name: "libcxx_test_bridge_code",
    tools: ["cxxbridge"],
    cmd: "$(location cxxbridge) $(in) > $(out)",
    srcs: ["lib.rs"],
    out: ["lib.rs.cc"],
}
  • Инструмент cxxbridge — самостоятельная утилита, которая генерирует сторону C++ модуля моста. Она входит в Android и доступна как инструмент Soong.
  • По соглашению, если ваш исходный файл Rust называется lib.rs, заголовочный файл будет называться lib.rs.h, а исходный файл — lib.rs.cc. Однако это соглашение не навязывается принудительно.

Сборка в Android

Создайте cc_library_static, чтобы собрать библиотеку C++, включая заголовок и исходный файл, сгенерированные CXX.

cc_library_static {
    name: "libcxx_test_cpp",
    srcs: ["cxx_test.cpp"],
    generated_headers: [
        "cxx-bridge-header",
        "libcxx_test_bridge_header"
    ],
    generated_sources: ["libcxx_test_bridge_code"],
}
  • Укажите, что libcxx_test_bridge_header и libcxx_test_bridge_code — зависимости для привязок C++, сгенерированных CXX. Как их настроить, покажем на следующем слайде.
  • Обратите внимание, что также нужно зависеть от библиотеки cxx-bridge-header, чтобы подтянуть общие определения CXX.
  • Полная документация по использованию CXX в Android находится в документации Android. Вы можете поделиться этой ссылкой с группой, чтобы студенты знали, где найти эти инструкции в будущем.

Сборка в Android

Создайте rust_binary, который зависит от libcxx и вашей cc_library_static.

rust_binary {
    name: "cxx_test",
    srcs: ["lib.rs"],
    rustlibs: ["libcxx"],
    static_libs: ["libcxx_test_cpp"],
}

Взаимодействие с Java

Java может загружать разделяемые объекты через Java Native Interface (JNI). Крейт jni позволяет создать совместимую библиотеку.

Сначала создадим функцию Rust для экспорта в Java:

interoperability/java/src/lib.rs:

#![allow(unused)]
fn main() {
// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

//! Демонстрация FFI Rust <-> Java.

use jni::JNIEnv;
use jni::objects::{JClass, JString};
use jni::sys::jstring;

/// Реализация метода HelloWorld::hello.
// SAFETY: Нет другой глобальной функции с таким именем.
#[unsafe(no_mangle)]
pub extern "system" fn Java_HelloWorld_hello(
    mut env: JNIEnv,
    _class: JClass,
    name: JString,
) -> jstring {
    let input: String = env.get_string(&name).unwrap().into();
    let greeting = format!("Hello, {input}!");
    let output = env.new_string(greeting).unwrap();
    output.into_raw()
}
}

interoperability/java/Android.bp:

rust_ffi_shared {
    name: "libhello_jni",
    crate_name: "hello_jni",
    srcs: ["src/lib.rs"],
    rustlibs: ["libjni"],
}

Затем вызываем эту функцию из Java:

interoperability/java/HelloWorld.java:

class HelloWorld {
    private static native String hello(String name);

    static {
        System.loadLibrary("hello_jni");
    }

    public static void main(String[] args) {
        String output = HelloWorld.hello("Алиса");
        System.out.println(output);
    }
}

interoperability/java/Android.bp:

java_binary {
    name: "helloworld_jni",
    srcs: ["HelloWorld.java"],
    main_class: "HelloWorld",
    jni_libs: ["libhello_jni"],
}

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

m helloworld_jni
adb sync  # requires adb root && adb remount
adb shell /system/bin/helloworld_jni
  • Атрибут unsafe(no_mangle) указывает Rust выдать символ Java_HelloWorld_hello ровно так, как он записан. Это важно, чтобы Java узнала символ как метод hello класса HelloWorld.

    • По умолчанию Rust искажает (переименовывает) символы, чтобы бинарный файл мог слинковать две версии одного и того же крейта Rust.

Добро пожаловать в Rust в Chromium

Rust поддерживается для сторонних библиотек в Chromium, а собственный клей-код (glue code) связывает Rust с существующим C++-кодом Chromium.

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

Настройка

Убедитесь, что вы можете собрать и запустить Chromium. Подходит любая платформа и любой набор флагов сборки, если ваш код относительно свежий (позиция коммита 1223636 и новее, что соответствует ноябрю 2023 года):

gn gen out/Debug
autoninja -C out/Debug chrome
out/Debug/chrome # or on Mac, out/Debug/Chromium.app/Contents/MacOS/Chromium

(Для самой быстрой итерации рекомендуется отладочная сборка в компонентном режиме (component build). Это режим по умолчанию!)

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

Также рекомендуется установить Visual Studio Code.

Об упражнениях

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

Сравнение экосистем Chromium и Cargo

Сообщество Rust обычно использует cargo и библиотеки с crates.io. Chromium собирается с помощью gn и ninja и тщательно подобранного набора зависимостей.

Когда вы пишете код на Rust, у вас есть такие варианты:

Дальше мы сосредоточимся на gn и ninja, потому что именно так код Rust собирается в браузер Chromium. При этом Cargo — важная часть экосистемы Rust, и её стоит держать в арсенале.

Мини-упражнение

Разбейтесь на небольшие группы и:

  • Придумайте сценарии, в которых cargo может дать преимущество, и оцените риски этих сценариев.
  • Обсудите, каким инструментам, библиотекам и группам людей нужно доверять при использовании gn и ninja, офлайн-cargo и т. д.

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

Заметки и подсказки к первой части упражнения («сценарии, где Cargo может дать преимущество»):

  • Замечательно, что при написании инструмента или прототипировании части Chromium доступна богатая экосистема библиотек crates.io. Почти для всего есть крейт, и обычно с ними приятно работать. (clap для разбора аргументов командной строки, serde для сериализации и десериализации в различные форматы, itertools для работы с итераторами и т. д.).

    • cargo упрощает опробование библиотеки (достаточно добавить одну строку в Cargo.toml и начать писать код).
    • Возможно, стоит сравнить, как CPAN помог сделать perl популярным выбором, или сравнить с python + pip.
  • Опыт разработки делают действительно удобным не только базовые инструменты Rust (например, использование rustup, чтобы переключиться на другую версию rustc при тестировании крейта, который должен работать на nightly, текущем stable и более старом stable), но и экосистема сторонних инструментов (например, Mozilla предоставляет cargo vet для упрощения и совместного использования аудитов безопасности; крейт criterion даёт удобный способ запускать бенчмарки).

    • cargo позволяет легко добавить инструмент командой cargo install --locked cargo-vet.
    • Возможно, стоит сравнить это с расширениями Chrome или расширениями VS Code.
  • Общие, обобщённые примеры проектов, где cargo может быть правильным выбором:

    • Возможно, неожиданно, но Rust всё популярнее в индустрии для написания инструментов командной строки. Широта и удобство библиотек сопоставимы с Python, но при этом код надёжнее (благодаря богатой системе типов) и работает быстрее (как компилируемый, а не интерпретируемый язык).
    • Участие в экосистеме Rust требует использования стандартных инструментов Rust, таких как Cargo. Библиотеки, которые хотят получать внешний вклад и использоваться за пределами Chromium (например, в средах сборки Bazel или Android/Soong), должны использовать Cargo.
  • Примеры связанных с Chromium проектов на базе cargo:

    • serde_json_lenient (его опробовали в других частях Google, что привело к PR с улучшениями производительности)
    • Библиотеки Fontations, например font-types
    • Инструмент gnrt (с ним мы встретимся позже в курсе), который зависит от clap для разбора командной строки и от toml для конфигурационных файлов.
      • Оговорка: одна особая причина использовать cargo — недоступность gn при сборке и начальной загрузке стандартной библиотеки Rust во время сборки инструментария Rust.
      • run_gnrt.py использует копию cargo и rustc из Chromium. gnrt зависит от сторонних библиотек, загружаемых из интернета, но run_gnrt.py требует от cargo разрешать только содержимое, зафиксированное в Cargo.lock (через --locked).)

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

  • rustc (компилятор Rust), который, в свою очередь, зависит от библиотек LLVM, компилятора Clang, исходников rustc (загружаются с GitHub и проверяются командой компилятора Rust), бинарного компилятора Rust, загружаемого для начальной загрузки
  • rustup (стоит отметить, что rustup разрабатывается под эгидой организации https://github.com/rust-lang/ — так же как и rustc)
  • cargo, rustfmt и т. д.
  • Различная внутренняя инфраструктура (боты, которые собирают rustc, система распространения готового инструментария инженерам Chromium и т. д.)
  • Инструменты для Cargo, например cargo audit, cargo vet и т. д.
  • Библиотеки Rust, внедрённые в //third_party/rust (проверены security@chromium.org)
  • Другие библиотеки Rust (некоторые нишевые, некоторые довольно популярные и широко используемые)

Политика Rust в Chromium

Политику Rust в Chromium можно найти здесь. Rust можно использовать как для собственного (first-party), так и для стороннего (third-party) кода.

Использование Rust для чисто собственного кода выглядит так:

RustLanguageboundaryChromiumcodeExistingChromiumRustC++C++

Сторонний случай также распространён. Обычно также нужен небольшой объём собственного клей-кода, потому что очень немногие библиотеки Rust напрямую предоставляют API для C/C++.

RustLanguageboundaryExistingcrateCrateAPIExistingChromiumChromiumRustRustC++C++wrapper

Сценарий со сторонним крейтом сложнее, поэтому сегодняшний курс будет сосредоточен на:

  • Подключении сторонних библиотек Rust («крейтов»)
  • Написании клей-кода, чтобы можно было использовать эти крейты из C++ в Chromium. (Те же приёмы используются при работе с собственным кодом Rust.)

Правила сборки

Код Rust обычно собирается с помощью cargo. Chromium собирается с gn и ninja ради эффективности — их статические правила обеспечивают максимальный параллелизм. Rust здесь не исключение.

Добавление кода Rust в Chromium

В каком-либо существующем файле Chromium BUILD.gn объявите rust_static_library:

import("//build/rust/rust_static_library.gni")

rust_static_library("my_rust_lib") {
  crate_root = "lib.rs"
  sources = [ "lib.rs" ]
}

Также можно добавить deps на другие цели Rust. Позже мы используем это, чтобы зависеть от стороннего кода.

Нужно указать и корень крейта, и полный список исходников. crate_root — файл, который передаётся компилятору Rust как корневой файл единицы компиляции, обычно lib.rs. sources — полный список всех исходных файлов, которые ninja нужны, чтобы определить, когда нужна пересборка.

(Такого понятия, как source_set для Rust, нет, потому что в Rust единица компиляции — это целый крейт. Наименьшая единица — static_library.)

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

Включение небезопасного кода Rust

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

import("//build/rust/rust_static_library.gni")

rust_static_library("my_rust_lib") {
  crate_root = "lib.rs"
  sources = [
    "lib.rs",
    "hippopotamus.rs"
  ]
  allow_unsafe = true
}

Зависимости от Rust-кода из C++ в Chromium

Просто добавьте приведённую выше цель в deps какой-нибудь цели Chromium на C++.

import("//build/rust/rust_static_library.gni")

rust_static_library("my_rust_lib") {
  crate_root = "lib.rs"
  sources = [ "lib.rs" ]
}

# or source_set, static_library etc.
component("preexisting_cpp") {
  deps = [ ":my_rust_lib" ]
}
We'll see that this relationship only works if the Rust code exposes plain C APIs which can be called from C++, or if we use a C++/Rust interop tool.

Visual Studio Code

В коде Rust типы часто опускаются, что делает хорошую IDE ещё полезнее, чем для C++. Visual Studio Code хорошо работает с Rust в Chromium. Чтобы его использовать,

  • Убедитесь, что в VS Code установлено расширение rust-analyzer, а не более ранние варианты поддержки Rust
  • gn gen out/Debug --export-rust-project (или эквивалентная команда для вашего каталога сборки)
  • ln -s out/Debug/rust-project.json rust-project.json
Example screenshot from VSCode

Демонстрация некоторых возможностей rust-analyzer по аннотированию и изучению кода может быть полезна, если аудитория скептически относится к IDE.

Следующие шаги могут помочь с демонстрацией (но можно вместо этого использовать фрагмент связанного с Chromium кода на Rust, который вам знаком лучше всего):

  • Откройте components/qr_code_generator/qr_code_generator_ffi_glue.rs
  • Поставьте курсор на вызов QrCode::new (примерно в строке 26) в qr_code_generator_ffi_glue.rs
  • Продемонстрируйте показ документации (типичные привязки: VS Code = ctrl k i; vim/CoC = K).
  • Продемонстрируйте переход к определению (типичные привязки: VS Code = F12; vim/CoC = g d). (Это перенесёт вас в //third_party/rust/.../qr_code-.../src/lib.rs.)
  • Продемонстрируйте структуру (outline) и перейдите к методу QrCode::with_bits (примерно в строке 164; структура находится в панели файлового проводника VS Code; типичные привязки vim/CoC = space o)
  • Продемонстрируйте аннотации типов (в методе QrCode::with_bits есть несколько хороших примеров)

Возможно, стоит отметить, что gn gen ... --export-rust-project нужно будет запустить снова после редактирования файлов BUILD.gn (что мы будем делать несколько раз в ходе упражнений этой сессии).

Упражнение по правилам сборки

В вашей сборке Chromium добавьте новую цель Rust в //ui/base/BUILD.gn, содержащую:

#![allow(unused)]
fn main() {
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

// SAFETY: Нет другой глобальной функции с таким именем.
#[unsafe(no_mangle)]
pub extern "C" fn hello_from_rust() {
    println!("Hello from Rust!")
}
}

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

Добавьте эту новую цель Rust как зависимость //ui/base:base. Объявите эту функцию в начале ui/base/resource/resource_bundle.cc (позже мы увидим, как это можно автоматизировать с помощью инструментов генерации привязок):

extern "C" void hello_from_rust();

Вызовите эту функцию откуда-нибудь из ui/base/resource/resource_bundle.cc — предлагаем начало ResourceBundle::MaybeMangleLocalizedString. Соберите и запустите Chromium и убедитесь, что «Hello from Rust!» выводится много раз.

Если вы используете VS Code, сейчас настройте Rust так, чтобы он хорошо работал в VS Code. Это пригодится в последующих упражнениях. Если у вас получилось, вы сможете использовать «Go to definition» по правому клику на println!.

Где найти помощь

It's really important that students get this running, because future exercises will build on it.

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

allow_unsafe = true здесь обязательно, потому что #[unsafe(no_mangle)] может позволить Rust сгенерировать две функции с одним и тем же именем, и тогда Rust больше не может гарантировать, что будет вызвана нужная.

Если нужен чисто Rust исполняемый файл, его можно собрать и с помощью шаблона gn rust_executable.

Тестирование

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

#![allow(unused)]
fn main() {
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[cfg(test)]
mod tests {
    #[test]
    fn my_test() {
        todo!()
    }
}
}

В Chromium мы помещаем модульные тесты в отдельный исходный файл, и для Rust продолжаем следовать этой практике — так тесты всегда легко найти, и не приходится дважды пересобирать файлы .rs (в конфигурации test).

Для тестирования кода Rust в Chromium есть следующие варианты:

  • Нативные тесты Rust (то есть #[test]). Вне //third_party/rust их не рекомендуется использовать.
  • Тесты gtest, написанные на C++, которые проверяют Rust через вызовы FFI. Этого достаточно, когда код Rust — это тонкий слой FFI, а существующие модульные тесты уже покрывают функциональность.
  • Тесты gtest, написанные на Rust, которые используют тестируемый крейт через его публичный API (при необходимости через pub mod for_testing { ... }). Этому посвящено несколько следующих слайдов.

Упомяните, что нативные тесты Rust для сторонних крейтов в итоге должны запускаться ботами Chromium. (Такое тестирование нужно редко — только после добавления или обновления сторонних крейтов.)

Несколько примеров помогут проиллюстрировать, когда следует использовать gtest на C++, а когда gtest на Rust:

  • В QR очень мало функциональности в собственном слое Rust (это просто тонкий клей FFI), поэтому для тестирования и реализации на C++, и реализации на Rust используются существующие модульные тесты C++ (тесты параметризуются так, чтобы включать или отключать Rust через ScopedFeatureList).

  • Гипотетическая или разрабатываемая интеграция PNG может требовать безопасных по памяти реализаций преобразований пикселей, которые предоставляет libpng, но которых нет в крейте png, например RGBA => BGRA или гамма-коррекция. Такая функциональность может выиграть от отдельных тестов, написанных на Rust.

Библиотека rust_gtest_interop

Библиотека rust_gtest_interop предоставляет способ:

  • Использовать функцию Rust как тестовый случай gtest (с помощью атрибута #[gtest(...)])
  • Использовать expect_eq! и подобные макросы (похожи на assert_eq!, но не вызывают панику и не прерывают тест при провале проверки).

Пример:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use rust_gtest_interop::prelude::*;

#[gtest(MyRustTestSuite, MyAdditionTest)]
fn test_addition() {
    expect_eq!(2 + 2, 4);
}

Правила GN для тестов Rust

Самый простой способ собрать тесты Rust gtest — добавить их в существующий бинарный файл тестов, который уже содержит тесты на C++. Например:

test("ui_base_unittests") {
  ...
  sources += [ "my_rust_lib_unittest.rs" ]
  deps += [ ":my_rust_lib" ]
}

Написание тестов Rust в отдельной static_library тоже работает, но требует вручную объявить зависимость от библиотек поддержки:

rust_static_library("my_rust_lib_unittests") {
  testonly = true
  is_gtest_unittests = true
  crate_root = "my_rust_lib_unittest.rs"
  sources = [ "my_rust_lib_unittest.rs" ]
  deps = [
    ":my_rust_lib",
    "//testing/rust_gtest_interop",
  ]
}

test("ui_base_unittests") {
  ...
  deps += [ ":my_rust_lib_unittests" ]
}

Макрос chromium::import!

После добавления :my_rust_lib в deps GN нам всё ещё нужно научиться импортировать и использовать my_rust_lib из my_rust_lib_unittest.rs. Мы не задали явный crate_name для my_rust_lib, поэтому его имя крейта вычисляется на основе полного пути и имени цели. К счастью, можно не работать с таким громоздким именем, используя макрос chromium::import! из автоматически импортируемого крейта chromium:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

chromium::import! {
    "//ui/base:my_rust_lib";
}

use my_rust_lib::my_function_under_test;

Под капотом макрос раскрывается примерно так:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

extern crate ui_sbase_cmy_urust_ulib as my_rust_lib;

use my_rust_lib::my_function_under_test;

Подробнее см. в комментарии к документации макроса chromium::import.

rust_static_library поддерживает явное задание имени через свойство crate_name, но делать так не рекомендуется. Не рекомендуется потому, что имя крейта должно быть глобально уникальным. crates.io гарантирует уникальность имён своих крейтов, поэтому цели GN cargo_crate (генерируемые инструментом gnrt, о котором речь пойдёт в следующем разделе) используют короткие имена крейтов.

Упражнение по тестированию

Время для ещё одного упражнения!

В вашей сборке Chromium:

  • Добавьте тестируемую функцию рядом с hello_from_rust. Некоторые варианты: сложение двух целых, переданных как аргументы, вычисление n-го числа Фибоначчи, суммирование целых в срезе и т. д.
  • Добавьте отдельный файл ..._unittest.rs с тестом для новой функции.
  • Добавьте новые тесты в BUILD.gn.
  • Соберите тесты, запустите их и убедитесь, что новый тест работает.

Взаимодействие с C++

В сообществе Rust есть несколько вариантов взаимодействия C++ и Rust, и новые инструменты постоянно разрабатываются. Сейчас Chromium использует инструмент под названием CXX.

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

Overview diagram of cxx, showing that the same interface definition is used to create both C++ and Rust side code which then communicate via a lowest common denominator C API

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

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

  • Инструмент гарантирует согласованность сторон C++ и Rust (например, вы получите ошибки компиляции, если #[cxx::bridge] не соответствует реальным определениям на C++ или Rust, тогда как при рассинхронизированных ручных привязках возникло бы неопределённое поведение)
  • Инструмент автоматизирует генерацию FFI-обёрток (тонких, совместимых с C-ABI свободных функций) для возможностей, которых нет в C (например, для вызовов FFI в методы Rust или C++; при ручных привязках такие функции верхнего уровня пришлось бы писать вручную)
  • Инструмент и библиотека умеют работать с набором базовых типов, например:
    • &[T] можно передавать через границу FFI, хотя он не гарантирует никакого конкретного ABI или раскладки памяти. При ручных привязках std::span<T> / &[T] приходится вручную разбирать и собирать заново из указателя и длины, что чревато ошибками, поскольку каждый язык по-разному представляет пустые срезы.
    • Умные указатели, такие как std::unique_ptr<T>, std::shared_ptr<T> и/или Box, поддерживаются нативно. При ручных привязках пришлось бы передавать совместимые с C-ABI сырые указатели, что увеличило бы риски времён жизни и безопасности памяти.
    • Типы rust::String и CxxString понимают и сохраняют различия представления строк между языками (например, rust::String::lossy может создать строку Rust из входных данных, не являющихся UTF-8, а rust::String::c_str может завершить строку NUL-символом).

Пример привязок

CXX требует, чтобы вся граница C++/Rust объявлялась в модулях cxx::bridge внутри исходного кода .rs.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[cxx::bridge]
mod ffi {
    extern "Rust" {
        type MultiBuf;

        fn next_chunk(buf: &mut MultiBuf) -> &[u8];
    }

    unsafe extern "C++" {
        include!("example/include/blobstore.h");

        type BlobstoreClient;

        fn new_blobstore_client() -> UniquePtr<BlobstoreClient>;
        fn put(self: &BlobstoreClient, buf: &mut MultiBuf) -> Result<u64>;
    }
}

// Определения типов и функций Rust располагаются здесь

Обратите внимание:

  • Хотя это выглядит как обычный модуль Rust mod, процедурный макрос #[cxx::bridge] делает с ним сложные вещи. Сгенерированный код гораздо сложнее, хотя в вашем коде по-прежнему получается mod с именем ffi.
  • Нативная поддержка std::unique_ptr из C++ в Rust
  • Встроенная поддержка срезов Rust в C++
  • Вызовы из C++ в Rust и типы Rust (в верхней части)
  • Вызовы из Rust в C++ и типы C++ (в нижней части)

Распространённое заблуждение: кажется, что Rust разбирает заголовок C++, но это обманчиво. Этот заголовок Rust никогда не интерпретирует, а просто #include-ится в сгенерированный код C++ для компиляторов C++.

Ограничения CXX

Ограничения CXX

Самая полезная страница при использовании CXX — справочник по типам.

CXX по сути подходит для случаев, когда:

  • Интерфейс между Rust и C++ достаточно прост, и его можно полностью описать.
  • Вы используете только типы, которые CXX уже поддерживает нативно, например std::unique_ptr, std::string, &[u8] и т. д.

У него много ограничений — например, нет поддержки типа Option из Rust.

Эти ограничения вынуждают использовать Rust в Chromium только для хорошо изолированных «листовых узлов», а не для произвольного взаимодействия Rust и C++. При рассмотрении применения Rust в Chromium хорошей отправной точкой будет набросать привязки CXX для границы языков, чтобы посмотреть, достаточно ли она проста.

In addition, right now, Rust code in one component cannot depend on Rust code in another, due to linking details in our component build. That's another reason to restrict Rust to use in leaf nodes.

Стоит также обсудить другие неудобные моменты CXX, например:

  • Его обработка ошибок основана на исключениях C++ (показано на следующем слайде)
  • Указатели на функции неудобны в использовании.

Обработка ошибок в CXX

Поддержка Result<T,E> в CXX опирается на исключения C++, поэтому в Chromium её использовать нельзя. Альтернативы:

  • Часть T в Result<T, E> может быть:

    • Возвращена через выходные параметры (например, через &mut T). Для этого T должно передаваться через границу FFI, то есть быть, например:
      • примитивным типом (например, u32 или usize)
      • типом, который cxx поддерживает нативно (например, UniquePtr<T>) и у которого есть подходящее значение по умолчанию для случая ошибки (в отличие от Box<T>).
    • Сохранена на стороне Rust и открыта через ссылку. Это может понадобиться, когда T — тип Rust, который нельзя передать через границу FFI и нельзя поместить в UniquePtr<T>.
  • Часть E в Result<T, E> может быть:

    • Возвращена как логическое значение (например, true означает успех, а false — неудачу)
    • Теоретически можно сохранить подробности ошибки, но на практике пока в этом не возникало необходимости.

Обработка ошибок CXX: пример с QR-кодом

Генератор QR-кодов — пример, где логическое значение используется для передачи успеха или неудачи, а успешный результат может быть передан через границу FFI:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[cxx::bridge(namespace = "qr_code_generator")]
mod ffi {
    extern "Rust" {
        fn generate_qr_code_using_rust(
            data: &[u8],
            min_version: i16,
            out_pixels: Pin<&mut CxxVector<u8>>,
            out_qr_size: &mut usize,
        ) -> bool;
    }
}

Студентов может заинтересовать семантика выходного параметра out_qr_size. Это не размер вектора, а размер QR-кода (и, честно говоря, это немного избыточно — это квадратный корень из размера вектора).

Стоит обратить внимание на важность инициализации out_qr_size перед вызовом функции Rust. Создание ссылки Rust, которая указывает на неинициализированную память, приводит к неопределённому поведению (в отличие от C++, где UB возникает только при самом разыменовании такой памяти).

Если студенты спросят про Pin, объясните, зачем CXX нужен Pin для изменяемых ссылок на данные C++: дело в том, что данные C++ нельзя перемещать, как данные Rust, потому что они могут содержать самоссылающиеся указатели.

Обработка ошибок CXX: пример с PNG

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[cxx::bridge(namespace = "gfx::rust_bindings")]
mod ffi {
    extern "Rust" {
        /// Это возвращает эквивалент `Result<PngReader<'a>,
        /// ()>`, удобный для FFI.
        fn new_png_reader<'a>(input: &'a [u8]) -> Box<ResultOfPngReader<'a>>;

        /// Привязки C++ для типа `crate::png::ResultOfPngReader`.
        type ResultOfPngReader<'a>;
        fn is_err(self: &ResultOfPngReader) -> bool;
        fn unwrap_as_mut<'a, 'b>(
            self: &'b mut ResultOfPngReader<'a>,
        ) -> &'b mut PngReader<'a>;

        /// Привязки C++ для типа `crate::png::PngReader`.
        type PngReader<'a>;
        fn height(self: &PngReader) -> u32;
        fn width(self: &PngReader) -> u32;
        fn read_rgba8(self: &mut PngReader, output: &mut [u8]) -> bool;
    }
}

PngReader и ResultOfPngReader — типы Rust: объекты этих типов не могут пересекать границу FFI без косвенности через Box<T>. Мы не можем иметь out_parameter: &mut PngReader, потому что CXX не позволяет C++ хранить объекты Rust по значению.

Этот пример показывает, что хотя CXX не поддерживает произвольные обобщения и шаблоны, их всё равно можно передать через границу FFI, вручную специализировав (мономорфизировав) их в необобщённый тип. В примере ResultOfPngReader — необобщённый тип, который перенаправляет вызовы подходящим методам Result<T, E> (например, is_err, unwrap и/или as_mut).

Использование CXX в Chromium

Использование cxx в Chromium

В Chromium мы определяем независимый модуль #[cxx::bridge] mod для каждого листового узла, где хотим использовать Rust. Обычно такой модуль есть для каждой rust_static_library. Просто добавьте

cxx_bindings = [ "my_rust_file.rs" ]
   # list of files containing #[cxx::bridge], not all source files
allow_unsafe = true

в существующую цель rust_static_library рядом с crate_root и sources.

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

#include "ui/base/my_rust_file.rs.h"

В //base есть вспомогательные функции для преобразования типов C++ Chromium в типы Rust CXX и обратно, например SpanToRustSlice.

Студенты могут спросить: зачем всё-таки нужен allow_unsafe = true?

Общий ответ: ни один код C/C++ не является «безопасным» по обычным меркам Rust. Вызовы в C/C++ и обратно из Rust могут делать с памятью что угодно и подорвать безопасность собственных раскладок данных Rust. Наличие слишком большого количества ключевых слов unsafe во взаимодействии с C/C++ может ухудшить отношение сигнала к шуму для такого ключевого слова, и это спорный вопрос. Но строго говоря, введение любого стороннего кода в бинарный файл Rust может вызвать неожиданное поведение с точки зрения Rust.

Узкий ответ — на диаграмме вверху этой страницы: за кулисами CXX генерирует небезопасные функции unsafe и extern "C", точно так же, как мы делали вручную в предыдущем разделе.

Упражнение: взаимодействие с C++

Часть первая

  • В файле Rust, который вы создали ранее, добавьте #[cxx::bridge], в котором указана одна функция, вызываемая из C++, hello_from_rust, без параметров и без возвращаемого значения.
  • Измените вашу прежнюю функцию hello_from_rust, убрав extern "C" и #[unsafe(no_mangle)]. Теперь это обычная функция Rust.
  • Измените вашу цель gn, чтобы собирать эти привязки.
  • В вашем коде C++ удалите предварительное объявление hello_from_rust. Вместо этого подключите сгенерированный заголовочный файл.
  • Соберите и запустите!

Часть вторая

Полезно немного поэкспериментировать с CXX. Это поможет понять, насколько гибким на самом деле может быть Rust в Chromium.

Что можно попробовать:

  • Вызовите C++ из Rust. Вам понадобится:
    • Дополнительный заголовочный файл, который можно подключить через include! из вашего cxx::bridge. В этом новом заголовочном файле вам нужно объявить вашу функцию C++.
    • Блок unsafe для вызова такой функции либо указание ключевого слова unsafe в вашем #[cxx::bridge], как описано здесь.
    • Возможно, также понадобится #include "third_party/rust/cxx/v1/crate/include/cxx.h"
  • Передайте строку C++ из C++ в Rust.
  • Передайте ссылку на объект C++ в Rust.
  • Намеренно сделайте сигнатуры функций Rust не совпадающими с #[cxx::bridge] и привыкните к ошибкам, которые вы увидите.
  • Намеренно сделайте сигнатуры функций C++ не совпадающими с #[cxx::bridge] и привыкните к ошибкам, которые вы увидите.
  • Передайте std::unique_ptr некоторого типа из C++ в Rust, чтобы Rust мог владеть объектом C++.
  • Создайте объект Rust и передайте его в C++, чтобы C++ владел им. (Подсказка: вам понадобится Box.)
  • Объявите некоторые методы в типе C++. Вызовите их из Rust.
  • Объявите некоторые методы в типе Rust. Вызовите их из C++.

Часть третья

Теперь, когда вы понимаете сильные стороны и ограничения взаимодействия через CXX, придумайте пару вариантов применения Rust в Chromium, где интерфейс был бы достаточно простым. Набросайте, как бы вы определили этот интерфейс.

Где найти помощь

As students explore Part Two, they're bound to have lots of questions about how to achieve these things, and also how CXX works behind the scenes.

Некоторые вопросы, с которыми вы можете столкнуться:

  • Я вижу проблему при инициализации переменной типа X значением типа Y, где X и Y — оба типы функций. Это потому, что ваша функция C++ не совсем совпадает с объявлением в вашем cxx::bridge.
  • Я, похоже, могу свободно превращать ссылки C++ в ссылки Rust. Не рискует ли это неопределённым поведением? Для непрозрачных (opaque) типов CXX — нет, потому что они имеют нулевой размер. Для тривиальных типов CXX — да, вызвать UB возможно, хотя конструкция CXX делает такой пример довольно трудным для создания.

Добавление сторонних крейтов

Библиотеки Rust называются «крейтами» и находятся на crates.io. Крейты Rust очень легко зависят друг от друга. Поэтому они и зависят!

ХарактеристикаC++Rust
Система сборкиМножествоСогласованность: Cargo.toml
Типичный размер библиотекиДовольно большаяМаленькая
Транзитивные зависимостиНемногоМножество

Для инженера Chromium это имеет плюсы и минусы:

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

Мы обсудим:

  • Как поместить крейт в дерево исходного кода Chromium
  • Как написать для него правила сборки gn
  • Как проверить его исходный код на достаточную безопасность.
All of the things in the table on this slide are generalizations, and counter-examples can be found. But in general it's important for students to understand that most Rust code depends on other Rust libraries, because it's easy to do so, and that this has both benefits and costs.

Настройка файла Cargo.toml для добавления крейтов

В Chromium есть единый набор централизованно управляемых прямых зависимостей от крейтов. Они ведутся через один Cargo.toml:

[dependencies]
bitflags = "1"
cfg-if = "1"
cxx = "1"
# lots more...

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

Когда вы добавляете крейт в Chromium, часто нужно указать дополнительную информацию в отдельном файле gnrt_config.toml, с которым мы встретимся дальше.

Настройка gnrt_config.toml

Рядом с Cargo.toml находится gnrt_config.toml. В нём содержатся расширения для обработки крейтов, специфичные для Chromium.

Если вы добавляете новый крейт, укажите хотя бы group. Это одно из значений:

#   'safe': The library satisfies the rule-of-2 and can be used in any process.
#   'sandbox': The library does not satisfy the rule-of-2 and must be used in
#              a sandboxed process such as the renderer or a utility process.
#   'test': The library is only used in tests.

Например,

[crate.my-new-crate]
group = 'test' # only used in test code

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

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

Загрузка крейтов

Инструмент gnrt умеет загружать крейты и генерировать правила BUILD.gn.

Для начала загрузите нужный крейт такой командой:

cd chromium/src
vpython3 tools/crates/run_gnrt.py -- vendor

Хотя инструмент gnrt входит в исходный код Chromium, выполняя эту команду, вы загрузите и запустите его зависимости с crates.io. См. раздел выше, где обсуждается это решение по безопасности.

Эта команда vendor может загрузить:

  • Ваш крейт
  • Прямые и транзитивные зависимости
  • Новые версии других крейтов, которые требуются cargo для разрешения полного набора крейтов, нужного Chromium.

Chromium поддерживает патчи для некоторых крейтов, которые хранятся в //third_party/rust/chromium_crates_io/patches. Они будут переприменяться автоматически, но если наложение патча не удалось, может потребоваться вручную вмешаться.

Генерация правил сборки gn

После загрузки крейта сгенерируйте файлы BUILD.gn такой командой:

vpython3 tools/crates/run_gnrt.py -- gen

Теперь выполните git status. Вы должны увидеть:

  • Как минимум один новый исходный код крейта в third_party/rust/chromium_crates_io/vendor
  • Как минимум один новый BUILD.gn в third_party/rust/<имя крейта>/v<основная версия semver>
  • Подходящий README.chromium

«Основная версия semver» — это номер версии semver в Rust.

Внимательно посмотрите, особенно на то, что генерируется в third_party/rust.

Немного поговорите о semver — в частности о том, что в Chromium принято допускать несколько несовместимых версий одного крейта. Это не приветствуется, но иногда необходимо в экосистеме Cargo.

Решение проблем

Если сборка падает, причина может быть в build.rs: программах, которые делают что угодно во время сборки. Это принципиально расходится с дизайном gn и ninja, которые стремятся к статическим, детерминированным правилам сборки, чтобы максимизировать параллелизм и повторяемость сборок.

Некоторые действия build.rs поддерживаются автоматически; другие требуют вмешательства:

эффект скрипта сборкиПоддерживается нашими шаблонами gnЧто нужно сделать вам
Проверка версии rustc для включения и отключения функцийДаНет
Проверка платформы или ЦП для включения и отключения функцийДаНет
Генерация кодаДаДа — укажите в gnrt_config.toml
Сборка C/C++НетОбойти патчем
Произвольные другие действияНетОбойти патчем

К счастью, большинство крейтов не содержат скрипта сборки, а большинство скриптов сборки выполняют только два верхних действия.

Скрипты сборки, генерирующие код

Если ninja жалуется на отсутствующие файлы, проверьте build.rs: не записывает ли он файлы с исходным кодом.

Если да, измените gnrt_config.toml, добавив крейту build-script-outputs. Если это транзитивная зависимость, то есть такая, от которой код Chromium не должен зависеть напрямую, также добавьте allow-first-party-usage=false. В этом файле уже есть несколько примеров:

[crate.unicode-linebreak]
allow-first-party-usage = false
build-script-outputs = ["tables.rs"]

Теперь повторно выполните gnrt.py -- gen, чтобы перегенерировать файлы BUILD.gn и сообщить ninja, что этот конкретный выходной файл является входом для последующих шагов сборки.

Скрипты сборки, собирающие C++ или выполняющие произвольные действия

Некоторые крейты используют крейт cc для сборки и линковки библиотек C/C++. Другие крейты разбирают C/C++ с помощью bindgen в своих скриптах сборки. Такие действия невозможно поддержать в контексте Chromium: наша система сборки на основе gn, ninja и LLVM очень строго описывает связи между действиями сборки.

Поэтому у вас есть такие варианты:

  • Избегать этих крейтов
  • Применить патч к крейту.

Патчи следует хранить в third_party/rust/chromium_crates_io/patches/<крейт> — см., например, патчи для крейта cxx — и gnrt будет применять их автоматически каждый раз, когда обновляет крейт.

Зависимость от крейта

После того как вы добавили сторонний крейт и сгенерировали правила сборки, зависеть от крейта просто. Найдите цель rust_static_library и добавьте dep на цель :lib внутри вашего крейта.

Конкретно:

cratenamemajorsemverversion//third_party/rust/v:lib

Например,

rust_static_library("my_rust_lib") {
  crate_root = "lib.rs"
  sources = [ "lib.rs" ]
  deps = [ "//third_party/rust/example_rust_crate/v1:lib" ]
}

Аудит сторонних крейтов

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

Со временем Chromium стремится перейти к процессу, основанному на cargo vet.

А пока для каждого добавляемого крейта мы проверяем следующее:

  • Поймите, зачем используется каждый крейт. Какова связь между крейтами? Если в системе сборки каждого крейта есть build.rs или процедурные макросы, разберитесь, для чего они. Совместимы ли они с тем, как обычно собирается Chromium?
  • Убедитесь, что каждый крейт поддерживается достаточно хорошо
  • Используйте cd third-party/rust/chromium_crates_io; cargo audit, чтобы проверить наличие известных уязвимостей (сначала понадобится cargo install cargo-audit, что по иронии связано с загрузкой множества зависимостей из интернета2)
  • Убедитесь, что любой unsafe код соответствует Правилу двух
  • Проверьте использование API fs и net
  • Прочитайте весь код на достаточном уровне, чтобы заметить что-то неуместное, что могло быть злонамеренно вставлено. (На практике нельзя добиться 100% идеальности: кода часто слишком много.)

Это лишь рекомендации — работайте с рецензентами из security@chromium.org, чтобы выяснить правильный способ убедиться в безопасности крейта.

Добавление крейтов в исходный код Chromium

git status должен показать:

  • Код крейта в //third_party/rust/chromium_crates_io
  • Метаданные (BUILD.gn и README.chromium) в //third_party/rust/<крейт>/<версия>

Пожалуйста, также добавьте файл OWNERS в последнее расположение.

Всё это, а также изменения Cargo.toml и gnrt_config.toml, нужно внести в репозиторий Chromium.

Важно: нужно использовать git add -f, иначе некоторые файлы могут быть пропущены из-за файлов .gitignore.

При этом могут сработать предварительные проверки (presubmit) из-за неинклюзивной лексики. Дело в том, что данные крейтов Rust часто содержат имена git-веток, а во многих проектах там до сих пор используется неинклюзивная терминология. Поэтому может понадобиться выполнить:

infra/update_inclusive_language_presubmit_exempt_dirs.sh > infra/inclusive_language_presubmit_exempt_dirs.txt
git add -p infra/inclusive_language_presubmit_exempt_dirs.txt # add whatever changes are yours

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

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

Упражнение

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

(В следующем упражнении мы будем использовать uwuify из Chromium, но при желании можно перейти к нему сразу. Или можно создать новую цель rust_executable, которая использует uwuify.)

Студентам придётся загрузить много транзитивных зависимостей.

Всего нужны следующие крейты:

  • instant,
  • lock_api,
  • parking_lot,
  • parking_lot_core,
  • redox_syscall,
  • scopeguard,
  • smallvec, и
  • uwuify.

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

Спасибо Daniel Liu за этот крейт!

Итоговое упражнение — «Всё вместе»

В этом упражнении вы добавите в Chromium совершенно новую функцию, объединив всё, что уже изучили.

Задание от отдела продуктов

В отдалённом тропическом лесу обнаружено сообщество пикси. Важно как можно скорее доставить им Chromium for Pixies.

Требование — перевести все строки интерфейса Chromium на язык пикси.

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

Фактически вы уже подключили этот крейт в предыдущем упражнении.

(Разумеется, настоящий перевод Chrome требует огромной внимательности и тщательности. Не выпускайте это!)

Шаги

Измените ResourceBundle::MaybeMangleLocalizedString так, чтобы она преобразовывала все строки через uwuify перед выводом. В этой специальной сборке Chromium это должно делаться всегда, независимо от значения mangle_localized_strings_.

Если вы всё сделали правильно во всех этих упражнениях, поздравляем: вы создали Chrome для пикси!

Chromium UI screenshot with uwu language
Students will likely need some hints here. Hints include:
  • UTF-16 и UTF-8. Студенты должны понимать, что строки Rust всегда в UTF-8, и обычно решают, что лучше выполнять преобразование на стороне C++ с помощью base::UTF16ToUTF8 и обратно.
  • Если студенты решат выполнять преобразование на стороне Rust, им нужно будет рассмотреть String::from_utf16, обработку ошибок и то, какие типы, поддерживаемые CXX, могут передавать много значений u16.
  • Студенты могут по-разному спроектировать границу C++/Rust, например принимать и возвращать строки по значению или брать изменяемую ссылку на строку. Если используется изменяемая ссылка, CXX, вероятно, скажет студенту, что нужно использовать Pin. Возможно, вам придётся объяснить, что делает Pin, и затем объяснить, зачем CXX нужен Pin для изменяемых ссылок на данные C++: дело в том, что данные C++ нельзя перемещать, как данные Rust, потому что они могут содержать самоссылающиеся указатели.
  • Цели C++, содержащей ResourceBundle::MaybeMangleLocalizedString, понадобится зависимость от цели rust_static_library. Студент, вероятно, уже это сделал.
  • Цели rust_static_library понадобится зависимость от //third_party/rust/uwuify/v0_2:lib.

Решения

Решения упражнений по Chromium можно найти в этой серии CL.

Либо, если вы предпочитаете «самостоятельные» решения, которые не требуют применения патчсетов или интеграции с основным кодом Chromium, их можно найти в подкаталоге //chromium/src/codelabs/rust в Chromium.

Добро пожаловать в Bare Metal Rust

Это самостоятельный однодневный курс о bare-metal Rust, рассчитанный на людей, знакомых с основами Rust (возможно, после прохождения курса Comprehensive Rust), и желательно также имеющих опыт bare-metal программирования на каком-либо другом языке, например C.

Сегодня мы поговорим о «bare-metal» Rust: запуске кода Rust без операционной системы под ним. Это будет разделено на несколько частей:

  • Что такое no_std Rust?
  • Написание прошивки для микроконтроллеров.
  • Написание кода загрузчика или ядра для прикладных процессоров.
  • Некоторые полезные крейты для разработки на bare-metal Rust.

Для части курса о микроконтроллерах в качестве примера мы будем использовать BBC micro:bit v2. Это отладочная плата на основе микроконтроллера Nordic nRF52833 с несколькими светодиодами и кнопками, акселерометром и компасом, подключёнными по I2C, и встроенным отладчиком SWD.

Чтобы начать, установите несколько инструментов, которые понадобятся позже. В gLinux или Debian:

sudo apt install gdb-multiarch libudev-dev picocom pkg-config qemu-system-arm build-essential
rustup update
rustup target add aarch64-unknown-none thumbv7em-none-eabihf
rustup component add llvm-tools-preview
cargo install cargo-binutils
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh

И дайте пользователям из группы plugdev доступ к программатору micro:bit:

echo 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0d28", MODE="0660", GROUP="logindev", TAG+="uaccess"' |\
  sudo tee /etc/udev/rules.d/50-microbit.rules
sudo udevadm control --reload-rules

Если устройство доступно, в выводе lsusb вы должны увидеть «NXP ARM mbed». Если вы используете Linux-среду на Chromebook, нужно открыть доступ к USB-устройству для Linux через chrome://os-settings/crostini/sharedUsbDevices.

В MacOS:

xcode-select --install
brew install gdb picocom qemu
rustup update
rustup target add aarch64-unknown-none thumbv7em-none-eabihf
rustup component add llvm-tools-preview
cargo install cargo-binutils
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh

no_std

core

alloc

std

  • Срезы, &str, CStr
  • NonZeroU8…
  • Option, Result
  • Display, Debug, write!…
  • Трейт Iterator
  • Error
  • panic!, assert_eq!…
  • NonNull и все обычные функции, связанные с указателями
  • Future и async/await
  • fence, AtomicBool, AtomicPtr, AtomicU32…
  • Duration
  • Box, Cow, Arc, Rc
  • Vec, BinaryHeap, BtreeMap, LinkedList, VecDeque
  • String, CString, format!
  • HashMap
  • Mutex, Condvar, Barrier, Once, RwLock, mpsc
  • File и остальное из fs
  • println!, Read, Write, Stdin, Stdout и остальное из io
  • Path, OsString
  • net
  • Command, Child, ExitCode
  • spawn, sleep и остальное из thread
  • SystemTime, Instant
  • HashMap зависит от генератора случайных чисел.
  • std реэкспортирует содержимое и core, и alloc.

Минимальная программа no_std

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#![no_main]
#![no_std]

use core::panic::PanicInfo;

#[panic_handler]
fn panic(_panic: &PanicInfo) -> ! {
    loop {}
}
  • Это скомпилируется в пустой бинарный файл.
  • std предоставляет обработчик паники; без него нам придётся предоставить свой.
  • Его также может предоставить другой крейт, например panic-halt.
  • В зависимости от цели, возможно, понадобится собирать с panic = "abort", чтобы избежать ошибки о eh_personality.
  • Обратите внимание, что точки входа main или любой другой нет; вам самим нужно определить свою точку входа. Обычно это включает скрипт компоновщика и некоторый ассемблерный код, который подготавливает всё для запуска кода Rust.

alloc

Чтобы использовать alloc, нужно реализовать глобальный (кучевой) аллокатор.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#![no_main]
#![no_std]

extern crate alloc;
extern crate panic_halt as _;

use alloc::string::ToString;
use alloc::vec::Vec;
use buddy_system_allocator::LockedHeap;

#[global_allocator]
static HEAP_ALLOCATOR: LockedHeap<32> = LockedHeap::<32>::new();

const HEAP_SIZE: usize = 65536;
static mut HEAP: [u8; HEAP_SIZE] = [0; HEAP_SIZE];

pub fn entry() {
    // SAFETY: `HEAP` используется только здесь, а `entry` вызывается только один раз.
    unsafe {
        // Дайте аллокатору немного памяти для выделения.
        HEAP_ALLOCATOR.lock().init(&raw mut HEAP as usize, HEAP_SIZE);
    }

    // Теперь мы можем делать то, что требует выделения памяти в куче.
    let mut v = Vec::new();
    v.push("A string".to_string());
}
  • buddy_system_allocator — крейт, реализующий простой аллокатор «системы двойников» (buddy system). Доступны и другие крейты, или вы можете написать свой аллокатор или подключиться к существующему.
  • Константный параметр LockedHeap — максимальный порядок аллокатора; то есть в данном случае он может выделять области размером до 2**32 байт.
  • Если какой-либо крейт в дереве зависимостей зависит от alloc, в вашем бинарном файле должен быть ровно один глобальный аллокатор. Обычно это делается в самом верхнем бинарном крейте.
  • extern crate panic_halt as _ необходим, чтобы крейт panic_halt был слинкован и мы получили его обработчик паники.
  • Этот пример соберётся, но не запустится, потому что у него нет точки входа.

Микроконтроллеры

Крейт cortex_m_rt предоставляет (среди прочего) обработчик сброса для микроконтроллеров Cortex-M.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#![no_main]
#![no_std]

extern crate panic_halt as _;

mod interrupts;

use cortex_m_rt::entry;

#[entry]
fn main() -> ! {
    loop {}
}

Далее посмотрим, как обращаться к периферии, с возрастающим уровнем абстракции.

  • Макрос cortex_m_rt::entry требует, чтобы функция имела тип fn() -> !, потому что возврат в обработчик сброса не имеет смысла.
  • Запустите пример командой cargo embed --bin minimal

Прямой доступ к MMIO

Большинство микроконтроллеров обращаются к периферии через ввод-вывод, отображённый в память (memory-mapped IO). Попробуем включить светодиод на нашем micro:bit:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#![no_main]
#![no_std]

extern crate panic_halt as _;

mod interrupts;

use core::mem::size_of;
use cortex_m_rt::entry;

/// Адрес периферии GPIO порта 0
const GPIO_P0: usize = 0x5000_0000;

// Смещения периферии GPIO
const PIN_CNF: usize = 0x700;
const OUTSET: usize = 0x508;
const OUTCLR: usize = 0x50c;

// Поля PIN_CNF
const DIR_OUTPUT: u32 = 0x1;
const INPUT_DISCONNECT: u32 = 0x1 << 1;
const PULL_DISABLED: u32 = 0x0 << 2;
const DRIVE_S0S1: u32 = 0x0 << 8;
const SENSE_DISABLED: u32 = 0x0 << 16;

#[entry]
fn main() -> ! {
    // Настраиваем выводы 21 и 28 GPIO 0 как выходы push-pull.
    let pin_cnf_21 = (GPIO_P0 + PIN_CNF + 21 * size_of::<u32>()) as *mut u32;
    let pin_cnf_28 = (GPIO_P0 + PIN_CNF + 28 * size_of::<u32>()) as *mut u32;
    // SAFETY: Указатели ведут на допустимые управляющие регистры периферии, и
    // никаких алиасов не существует.
    unsafe {
        pin_cnf_21.write_volatile(
            DIR_OUTPUT
                | INPUT_DISCONNECT
                | PULL_DISABLED
                | DRIVE_S0S1
                | SENSE_DISABLED,
        );
        pin_cnf_28.write_volatile(
            DIR_OUTPUT
                | INPUT_DISCONNECT
                | PULL_DISABLED
                | DRIVE_S0S1
                | SENSE_DISABLED,
        );
    }

    // Установите вывод 28 в низкий уровень, а вывод 21 --- в высокий, чтобы включить светодиод.
    let gpio0_outset = (GPIO_P0 + OUTSET) as *mut u32;
    let gpio0_outclr = (GPIO_P0 + OUTCLR) as *mut u32;
    // SAFETY: Указатели ведут на допустимые управляющие регистры периферии, и
    // никаких алиасов не существует.
    unsafe {
        gpio0_outclr.write_volatile(1 << 28);
        gpio0_outset.write_volatile(1 << 21);
    }

    loop {}
}
  • Вывод 21 GPIO 0 подключён к первому столбцу матрицы светодиодов, а вывод 28 — к первой строке.

Запустите пример командой:

cargo embed --bin mmio

Крейты доступа к периферии

svd2rust генерирует в основном безопасные обёртки на Rust для отображаемой в память периферии из файлов CMSIS-SVD.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#![no_main]
#![no_std]

extern crate panic_halt as _;

use cortex_m_rt::entry;
use nrf52833_pac::Peripherals;

#[entry]
fn main() -> ! {
    let p = Peripherals::take().unwrap();
    let gpio0 = p.P0;

    // Настраиваем выводы 21 и 28 GPIO 0 как выходы push-pull.
    gpio0.pin_cnf[21].write(|w| {
        w.dir().output();
        w.input().disconnect();
        w.pull().disabled();
        w.drive().s0s1();
        w.sense().disabled();
        w
    });
    gpio0.pin_cnf[28].write(|w| {
        w.dir().output();
        w.input().disconnect();
        w.pull().disabled();
        w.drive().s0s1();
        w.sense().disabled();
        w
    });

    // Установите вывод 28 в низкий уровень, а вывод 21 --- в высокий, чтобы включить светодиод.
    gpio0.outclr.write(|w| w.pin28().clear());
    gpio0.outset.write(|w| w.pin21().set());

    loop {}
}
  • Файлы SVD (System View Description) — это XML-файлы, обычно предоставляемые производителями кремния, которые описывают карту памяти устройства.
    • Они организованы по периферии, регистрам, полям и значениям с именами, описаниями, адресами и т. д.
    • Файлы SVD часто содержат ошибки и бывают неполными, поэтому существуют различные проекты, которые исправляют ошибки, добавляют недостающие детали и публикуют сгенерированные крейты.
  • cortex-m-rt, среди прочего, предоставляет таблицу векторов.
  • Если выполнить cargo install cargo-binutils, можно запустить cargo objdump --bin pac -- -d --no-show-raw-insn, чтобы посмотреть получившийся бинарный файл.

Запустите пример командой:

cargo embed --bin pac

HAL-крейты

HAL-крейты для многих микроконтроллеров предоставляют обёртки вокруг различной периферии. Обычно они реализуют трейты из embedded-hal.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#![no_main]
#![no_std]

extern crate panic_halt as _;

use cortex_m_rt::entry;
use embedded_hal::digital::OutputPin;
use nrf52833_hal::gpio::{Level, p0};
use nrf52833_hal::pac::Peripherals;

#[entry]
fn main() -> ! {
    let p = Peripherals::take().unwrap();

    // Создаём HAL-обёртку для GPIO порта 0.
    let gpio0 = p0::Parts::new(p.P0);

    // Настраиваем выводы 21 и 28 GPIO 0 как выходы push-pull.
    let mut col1 = gpio0.p0_28.into_push_pull_output(Level::High);
    let mut row1 = gpio0.p0_21.into_push_pull_output(Level::Low);

    // Установите вывод 28 в низкий уровень, а вывод 21 --- в высокий, чтобы включить светодиод.
    col1.set_low().unwrap();
    row1.set_high().unwrap();

    loop {}
}
  • set_low и set_high — методы трейта OutputPin из embedded_hal.
  • HAL-крейты существуют для многих устройств Cortex-M и RISC-V, включая различные микроконтроллеры STM32, GD32, nRF, NXP, MSP430, AVR и PIC.

Запустите пример командой:

cargo embed --bin hal

Крейты поддержки плат

Крейты поддержки плат предоставляют ещё один уровень обёрток для конкретной платы, для удобства.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#![no_main]
#![no_std]

extern crate panic_halt as _;

use cortex_m_rt::entry;
use embedded_hal::digital::OutputPin;
use microbit::Board;

#[entry]
fn main() -> ! {
    let mut board = Board::take().unwrap();

    board.display_pins.col1.set_low().unwrap();
    board.display_pins.row1.set_high().unwrap();

    loop {}
}
  • В данном случае крейт поддержки платы просто предоставляет более удобные имена и немного инициализации.
  • Крейт также может включать драйверы для некоторых устройств на плате вне самого микроконтроллера.
    • microbit-v2 включает простой драйвер для матрицы светодиодов.

Запустите пример командой:

cargo embed --bin board_support

Паттерн typestate

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[entry]
fn main() -> ! {
    let p = Peripherals::take().unwrap();
    let gpio0 = p0::Parts::new(p.P0);

    let pin: P0_01<Disconnected> = gpio0.p0_01;

    // let gpio0_01_again = gpio0.p0_01; // Ошибка, значение перемещено.
    let mut pin_input: P0_01<Input<Floating>> = pin.into_floating_input();
    if pin_input.is_high().unwrap() {
        // ...
    }
    let mut pin_output: P0_01<Output<OpenDrain>> = pin_input
        .into_open_drain_output(OpenDrainConfig::Disconnect0Standard1, Level::Low);
    pin_output.set_high().unwrap();
    // pin_input.is_high(); // Ошибка, значение перемещено.

    let _pin2: P0_02<Output<OpenDrain>> = gpio0
        .p0_02
        .into_open_drain_output(OpenDrainConfig::Disconnect0Standard1, Level::Low);
    let _pin3: P0_03<Output<PushPull>> =
        gpio0.p0_03.into_push_pull_output(Level::Low);

    loop {}
}
  • Выводы не реализуют Copy или Clone, поэтому каждый вывод может существовать только в единственном экземпляре. Как только вывод перемещён из структуры порта, никто другой не может его забрать.
  • Изменение конфигурации вывода потребляет старый экземпляр вывода, поэтому после этого старым экземпляром пользоваться нельзя.
  • Тип значения показывает, в каком состоянии оно находится: например, в этом случае — состояние конфигурации GPIO-вывода. Это кодирует конечный автомат в системе типов и гарантирует, что вы не попытаетесь использовать вывод определённым образом, не настроив его должным образом сначала. Недопустимые переходы состояний отлавливаются во время компиляции.
  • Можно вызвать is_high для входного вывода и set_high для выходного, но не наоборот.
  • Многие HAL-крейты следуют этому паттерну.

embedded-hal

Крейт embedded-hal предоставляет ряд трейтов, которые покрывают распространённую периферию микроконтроллеров:

  • GPIO
  • PWM
  • Таймеры задержки
  • Шины и устройства I2C и SPI

Аналогичные трейты для байтовых потоков (например, UART), шин CAN и генераторов случайных чисел вынесены в embedded-io, embedded-can и rand_core соответственно.

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

  • Трейты описывают использование периферии, но не инициализацию или настройку, так как инициализация и настройка сильно зависят от платформы.
  • Существуют реализации для многих микроконтроллеров, а также для других платформ, например Linux на Raspberry Pi.
  • embedded-hal-async предоставляет асинхронные версии трейтов.
  • embedded-hal-nb предлагает другой подход к неблокирующему вводу-выводу, основанный на крейте nb.

probe-rs и cargo-embed

probe-rs — удобный набор инструментов для отладки встраиваемых систем, похожий на OpenOCD, но лучше интегрированный.

  • SWD (Serial Wire Debug) и JTAG через зонды CMSIS-DAP, ST-Link и J-Link
  • Заглушка GDB и сервер Microsoft DAP (Debug Adapter Protocol)
  • Интеграция с Cargo

cargo-embed — подкоманда cargo для сборки и прошивки бинарных файлов, вывода логов RTT (Real Time Transfers) и подключения GDB. Настраивается файлом Embed.toml в каталоге проекта.

  • CMSIS-DAP — стандартный протокол Arm поверх USB, который позволяет внутрисхемному отладчику обращаться к CoreSight Debug Access Port различных процессоров Arm Cortex. Именно его использует встроенный отладчик BBC micro:bit.
  • ST-Link — линейка внутрисхемных отладчиков от ST Microelectronics, J-Link — линейка от SEGGER.
  • Debug Access Port обычно представляет собой 5-контактный интерфейс JTAG или 2-контактный Serial Wire Debug.
  • probe-rs — библиотека, которую при желании можно интегрировать в собственные инструменты.
  • Microsoft Debug Adapter Protocol позволяет VSCode и другим IDE отлаживать код, выполняющийся на любом поддерживаемом микроконтроллере.
  • cargo-embed — бинарный файл, собранный с использованием библиотеки probe-rs.
  • RTT (Real Time Transfers) — механизм передачи данных между хостом отладки и целевым устройством через несколько кольцевых буферов.

Отладка

Embed.toml:

[default.general]
chip = "nrf52833_xxAA"

[debug.gdb]
enabled = true

В одном терминале в src/bare-metal/microcontrollers/examples/:

cargo embed --bin board_support debug

В другом терминале в том же каталоге:

В gLinux или Debian:

gdb-multiarch target/thumbv7em-none-eabihf/debug/board_support --eval-command="target remote :1338"

В MacOS:

arm-none-eabi-gdb target/thumbv7em-none-eabihf/debug/board_support --eval-command="target remote :1338"

В GDB попробуйте выполнить:

b src/bin/board_support.rs:29
b src/bin/board_support.rs:30
b src/bin/board_support.rs:32
c
c
c

Другие проекты

  • RTIC
    • «Real-Time Interrupt-driven Concurrency» (конкурентность в реальном времени, управляемая прерываниями).
    • Управление общими ресурсами, передача сообщений, планирование задач, очередь таймеров.
  • Embassy
    • Исполнители async с приоритетами, таймеры, сеть, USB.
  • TockOS
    • RTOS, ориентированная на безопасность, с вытесняющим планированием и поддержкой Memory Protection Unit.
  • Hubris
    • Микроядерная RTOS от Oxide Computer Company с защитой памяти, непривилегированными драйверами и IPC.
  • Привязки для FreeRTOS.

Некоторые платформы имеют реализации std, например esp-idf.

  • RTIC можно рассматривать и как RTOS, и как фреймворк конкурентности.
    • Он не включает никаких HAL.
    • Для планирования он использует NVIC (Nested Virtual Interrupt Controller) Cortex-M, а не полноценное ядро.
    • Только Cortex-M.
  • Google использует TockOS на микроконтроллере Haven для ключей безопасности Titan.
  • FreeRTOS в основном написана на C, но для написания приложений есть привязки на Rust.

Упражнения

Мы будем считывать направление с I2C-компаса и записывать показания в последовательный порт.

Ознакомившись с упражнениями, вы можете посмотреть предложенные решения.

Compass

Мы будем считывать направление с I2C-компаса и записывать показания в последовательный порт. Если останется время, попробуйте как-нибудь вывести его и на светодиоды или использовать кнопки.

Подсказки:

  • Посмотрите документацию крейтов lsm303agr и microbit-v2, а также аппаратную часть micro:bit.
  • Инерциальный измерительный блок LSM303AGR подключён к внутренней шине I2C.
  • TWI — другое название I2C, поэтому периферия I2C-мастера называется TWIM.
  • Драйверу LSM303AGR нужно что-то, реализующее трейт embedded_hal::i2c::I2c. Структура microbit::hal::Twim реализует его.
  • У вас есть структура microbit::Board с полями для различных выводов и периферии.
  • Если хотите, можете также посмотреть даташит nRF52833, но для этого упражнения он не должен понадобиться.

Скачайте шаблон упражнения и найдите в каталоге compass следующие файлы.

src/main.rs:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#![no_main]
#![no_std]

extern crate panic_halt as _;

use core::fmt::Write;
use cortex_m_rt::entry;
use microbit::{hal::{Delay, uarte::{Baudrate, Parity, Uarte}}, Board};

#[entry]
fn main() -> ! {
    let mut board = Board::take().unwrap();

    // Configure serial port.
    let mut serial = Uarte::new(
        board.UARTE0,
        board.uart.into(),
        Parity::EXCLUDED,
        Baudrate::BAUD115200,
    );

    // Use the system timer as a delay provider.
    let mut delay = Delay::new(board.SYST);

    // Set up the I2C controller and Inertial Measurement Unit.
    // TODO

    writeln!(serial, "Ready.").unwrap();

    loop {
        // Read compass data and log it to the serial port.
        // TODO
    }
}

Cargo.toml (менять его, скорее всего, не нужно):

[workspace]

[package]
name = "compass"
version = "0.1.0"
edition = "2024"
publish = false

[dependencies]
cortex-m-rt = "0.7.5"
embedded-hal = "1.0.0"
lsm303agr = "1.1.0"
microbit-v2 = "0.16.0"
panic-halt = "1.0.0"

Embed.toml (менять его, скорее всего, не нужно):

[default.general]
chip = "nrf52833_xxAA"

[debug.gdb]
enabled = true

[debug.reset]
halt_afterwards = true

.cargo/config.toml (менять его, скорее всего, не нужно):

[build]
target = "thumbv7em-none-eabihf" # Cortex-M4F

[target.'cfg(all(target_arch = "arm", target_os = "none"))']
rustflags = ["-C", "link-arg=-Tlink.x"]

Посмотреть вывод последовательного порта в Linux можно так:

picocom --baud 115200 --imap lfcrlf /dev/ttyACM0

Или в Mac OS примерно так (имя устройства может немного отличаться):

picocom --baud 115200 --imap lfcrlf /dev/tty.usbmodem14502

Чтобы выйти из picocom, нажмите Ctrl+A Ctrl+Q.

Утреннее упражнение по Bare Metal Rust

Compass

(назад к упражнению)

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#![no_main]
#![no_std]

extern crate panic_halt as _;

use core::fmt::Write;
use cortex_m_rt::entry;
use embedded_hal::digital::InputPin;
use lsm303agr::{
    AccelMode, AccelOutputDataRate, Lsm303agr, MagMode, MagOutputDataRate,
};
use microbit::Board;
use microbit::display::blocking::Display;
use microbit::hal::twim::Twim;
use microbit::hal::uarte::{Baudrate, Parity, Uarte};
use microbit::hal::{Delay, Timer};
use microbit::pac::twim0::frequency::FREQUENCY_A;

const COMPASS_SCALE: i32 = 30000;
const ACCELEROMETER_SCALE: i32 = 700;

#[entry]
fn main() -> ! {
    let mut board = Board::take().unwrap();

    // Настраиваем последовательный порт.
    let mut serial = Uarte::new(
        board.UARTE0,
        board.uart.into(),
        Parity::EXCLUDED,
        Baudrate::BAUD115200,
    );

    // Используем системный таймер в качестве источника задержек.
    let mut delay = Delay::new(board.SYST);

    // Настраиваем контроллер I2C и инерциальный измерительный блок.
    writeln!(serial, "Setting up IMU...").unwrap();
    let i2c = Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100);
    let mut imu = Lsm303agr::new_with_i2c(i2c);
    imu.init().unwrap();
    imu.set_mag_mode_and_odr(
        &mut delay,
        MagMode::HighResolution,
        MagOutputDataRate::Hz50,
    )
    .unwrap();
    imu.set_accel_mode_and_odr(
        &mut delay,
        AccelMode::Normal,
        AccelOutputDataRate::Hz50,
    )
    .unwrap();
    let mut imu = imu.into_mag_continuous().ok().unwrap();

    // Настраиваем дисплей и таймер.
    let mut timer = Timer::new(board.TIMER0);
    let mut display = Display::new(board.display_pins);

    let mut mode = Mode::Compass;
    let mut button_pressed = false;

    writeln!(serial, "Ready.").unwrap();

    loop {
        // Читаем данные компаса и записываем их в последовательный порт.
        while !(imu.mag_status().unwrap().xyz_new_data()
            && imu.accel_status().unwrap().xyz_new_data())
        {}
        let compass_reading = imu.magnetic_field().unwrap();
        let accelerometer_reading = imu.acceleration().unwrap();
        writeln!(
            serial,
            "{},{},{}\t{},{},{}",
            compass_reading.x_nt(),
            compass_reading.y_nt(),
            compass_reading.z_nt(),
            accelerometer_reading.x_mg(),
            accelerometer_reading.y_mg(),
            accelerometer_reading.z_mg(),
        )
        .unwrap();

        let mut image = [[0; 5]; 5];
        let (x, y) = match mode {
            Mode::Compass => (
                scale(-compass_reading.x_nt(), -COMPASS_SCALE, COMPASS_SCALE, 0, 4)
                    as usize,
                scale(compass_reading.y_nt(), -COMPASS_SCALE, COMPASS_SCALE, 0, 4)
                    as usize,
            ),
            Mode::Accelerometer => (
                scale(
                    accelerometer_reading.x_mg(),
                    -ACCELEROMETER_SCALE,
                    ACCELEROMETER_SCALE,
                    0,
                    4,
                ) as usize,
                scale(
                    -accelerometer_reading.y_mg(),
                    -ACCELEROMETER_SCALE,
                    ACCELEROMETER_SCALE,
                    0,
                    4,
                ) as usize,
            ),
        };
        image[y][x] = 255;
        display.show(&mut timer, image, 100);

        // Если нажата кнопка A, переключаемся на следующий режим и кратко мигаем всеми светодиодами
        // (включаем).
        if board.buttons.button_a.is_low().unwrap() {
            if !button_pressed {
                mode = mode.next();
                display.show(&mut timer, [[255; 5]; 5], 200);
            }
            button_pressed = true;
        } else {
            button_pressed = false;
        }
    }
}

#[derive(Copy, Clone, Debug, Eq, PartialEq)]
enum Mode {
    Compass,
    Accelerometer,
}

impl Mode {
    fn next(self) -> Self {
        match self {
            Self::Compass => Self::Accelerometer,
            Self::Accelerometer => Self::Compass,
        }
    }
}

fn scale(value: i32, min_in: i32, max_in: i32, min_out: i32, max_out: i32) -> i32 {
    let range_in = max_in - min_in;
    let range_out = max_out - min_out;
    let scaled = min_out + range_out * (value - min_in) / range_in;
    scaled.clamp(min_out, max_out)
}

Прикладные процессоры

До сих пор мы говорили о микроконтроллерах, например серии Arm Cortex-M. Обычно это небольшие системы с очень ограниченными ресурсами.

Более крупные системы с большими ресурсами обычно называют прикладными процессорами; они построены на процессорах вроде ARM Cortex-A или Intel Atom.

Для простоты мы будем работать только с платой 'virt' для aarch64 из QEMU (документация).

  • В общих чертах, у микроконтроллеров нет MMU или нескольких уровней привилегий (уровни исключений у процессоров Arm, кольца у x86).
  • У прикладных процессоров больше ресурсов, и они часто запускают операционную систему, а не выполняют целевое приложение напрямую при старте.
  • QEMU поддерживает эмуляцию различных машин или моделей плат для каждой архитектуры. Плата ‘virt’ не соответствует какому-либо конкретному реальному оборудованию, а создана исключительно для виртуальных машин.
  • Мы всё равно будем называть эту плату bare-metal, как если бы писали операционную систему.

Подготовка к работе с Rust

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

/**
 * This is a generic entry point for an image. It carries out the
 * operations required to prepare the loaded image to be run.
 * Specifically, it
 *
 * - sets up the MMU with an identity map of virtual to physical
 *   addresses, and enables caching
 * - enables floating point
 * - zeroes the bss section using registers x25 and above
 * - prepares the stack, pointing to a section within the image
 * - sets up the exception vector
 * - branches to the Rust `main` function
 *
 * It preserves x0-x3 for the Rust entry point, as these may contain
 * boot parameters.
 */
.section .init.entry, "ax"
.global entry
entry:
    /*
     * Load and apply the memory management configuration, ready to
     * enable MMU and caches.
     */
    adrp x30, idmap
    msr ttbr0_el1, x30

    mov_i x30, .Lmairval
    msr mair_el1, x30

    mov_i x30, .Ltcrval
    /* Copy the supported PA range into TCR_EL1.IPS. */
    mrs x29, id_aa64mmfr0_el1
    bfi x30, x29, #32, #4

    msr tcr_el1, x30

    mov_i x30, .Lsctlrval

    /*
     * Ensure everything before this point has completed, then
     * invalidate any potentially stale local TLB entries before they
     * start being used.
     */
    isb
    tlbi vmalle1
    ic iallu
    dsb nsh
    isb

    /*
     * Configure sctlr_el1 to enable MMU and cache and don't proceed
     * until this has completed.
     */
    msr sctlr_el1, x30
    isb

    /* Disable trapping floating point access in EL1. */
    mrs x30, cpacr_el1
    orr x30, x30, #(0x3 << 20)
    msr cpacr_el1, x30
    isb

    /* Zero out the bss section. */
    adr_l x29, bss_begin
    adr_l x30, bss_end
0:  cmp x29, x30
    b.hs 1f
    stp xzr, xzr, [x29], #16
    b 0b

1:  /* Prepare the stack. */
    adr_l x30, boot_stack_end
    mov sp, x30

    /* Set up exception vector. */
    adr x30, vector_table_el1
    msr vbar_el1, x30

    /* Call into Rust code. */
    bl main

    /* Loop forever waiting for interrupts. */
2:  wfi
    b 2b

Этот код находится в src/bare-metal/aps/examples/src/entry.S. Понимать его в деталях не обязательно — главное, что для выполнения ожиданий Rust о системе нужна некоторая низкоуровневая настройка.

  • Это то же самое, что было бы для C: инициализация состояния процессора, обнуление BSS и настройка указателя стека.
    • BSS (block starting symbol, по историческим причинам) — часть объектного файла, которая содержит статически выделенные переменные, инициализированные нулём. Их не включают в образ, чтобы не тратить место на нули. Компилятор предполагает, что загрузчик позаботится об их обнулении.
  • BSS может быть уже обнулён в зависимости от того, как инициализируется память и загружается образ, но мы обнуляем его, чтобы быть уверенными.
  • Нам нужно включить MMU и кэш до чтения или записи любой памяти. Если этого не сделать:
    • Невыровненный доступ вызовет ошибку. Мы собираем код Rust для цели aarch64-unknown-none с +strict-align, чтобы компилятор не генерировал невыровненных обращений, поэтому в данном случае всё должно быть в порядке, но в общем случае это не обязательно так.
    • Если бы это работало в виртуальной машине, это могло бы привести к проблемам когерентности кэша. Проблема в том, что ВМ обращается к памяти напрямую при выключенном кэше, а у хоста есть кэшируемые алиасы к той же памяти. Даже если хост явно не обращается к памяти, спекулятивные обращения могут привести к заполнению кэша, и тогда изменения одной из сторон будут потеряны при очистке кэша или включении кэша ВМ. (Кэш индексируется по физическому адресу, а не по VA или IPA.)
  • Для простоты мы используем жёстко заданную таблицу страниц (см. idmap.S), которая отображает тождественно первый 1 ГиБ адресного пространства для устройств, следующий 1 ГиБ для DRAM и ещё 1 ГиБ выше для других устройств. Это соответствует раскладке памяти, которую использует QEMU.
  • Мы также настраиваем вектор исключений (vbar_el1), о котором подробнее поговорим позже.
  • Все примеры сегодня днём предполагают, что мы работаем на уровне исключений 1 (EL1). Если нужно работать на другом уровне исключений, нужно соответствующим образом изменить entry.S.

Встроенный ассемблер

Иногда нужно использовать ассемблер для того, что невозможно сделать кодом Rust. Например, чтобы выполнить HVC (hypervisor call), сообщая прошивке, что нужно выключить систему:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#![no_main]
#![no_std]

use core::arch::asm;
use core::panic::PanicInfo;

mod asm;
mod exceptions;

const PSCI_SYSTEM_OFF: u32 = 0x84000008;

// SAFETY: Нет другой глобальной функции с таким именем.
#[unsafe(no_mangle)]
extern "C" fn main(_x0: u64, _x1: u64, _x2: u64, _x3: u64) {
    // SAFETY: здесь используются только объявленные регистры, и мы ничего не делаем
    // с памятью.
    unsafe {
        asm!("hvc #0",
            inout("w0") PSCI_SYSTEM_OFF => _,
            inout("w1") 0 => _,
            inout("w2") 0 => _,
            inout("w3") 0 => _,
            inout("w4") 0 => _,
            inout("w5") 0 => _,
            inout("w6") 0 => _,
            inout("w7") 0 => _,
            options(nomem, nostack)
        );
    }

    loop {}
}

(Если вы действительно хотите это сделать, используйте крейт smccc, в котором есть обёртки для всех этих функций.)

  • PSCI — Arm Power State Coordination Interface, стандартный набор функций для управления состояниями питания системы и процессоров, среди прочего. Реализуется прошивкой EL3 и гипервизорами на многих системах.
  • Синтаксис 0 => _ означает, что регистр инициализируется нулём перед выполнением встроенного ассемблерного кода, а его содержимое после этого игнорируется. Нужно использовать inout, а не in, потому что вызов потенциально может перезаписать содержимое регистров.
  • Эта функция main должна быть #[unsafe(no_mangle)] и extern "C", потому что её вызывает наша точка входа в entry.S.
    • Достаточно было бы просто #[no_mangle], но RFC3325 использует эту запись, чтобы привлечь внимание рецензентов к атрибутам, которые могут вызвать неопределённое поведение при неправильном использовании.
  • _x0–_x3 — значения регистров x0–x3, которые по соглашению загрузчик использует для передачи, например, указателя на дерево устройств. Согласно стандартному соглашению о вызовах aarch64 (которое задаёт extern "C"), регистры x0–x7 используются для первых 8 аргументов функции, поэтому entry.S не нужно делать ничего особенного, кроме того, чтобы не менять эти регистры.
  • Запустите пример в QEMU командой make qemu_psci в каталоге src/bare-metal/aps/examples.

Энергозависимый (volatile) доступ к памяти для MMIO

  • Используйте pointer::read_volatile и pointer::write_volatile.
  • Никогда не держите ссылку на место, к которому обращаются через эти методы. Rust может в любой момент читать по ссылке (или записывать, для &mut).
  • Используйте &raw, чтобы получать поля структур без создания промежуточной ссылки.
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

const SOME_DEVICE_REGISTER: *mut u64 = 0x800_0000 as _;
// SAFETY: По этому адресу отображено некоторое устройство.
unsafe {
    SOME_DEVICE_REGISTER.write_volatile(0xff);
    SOME_DEVICE_REGISTER.write_volatile(0x80);
    assert_eq!(SOME_DEVICE_REGISTER.read_volatile(), 0xaa);
}
  • Энергозависимый доступ: операции чтения или записи могут иметь побочные эффекты, поэтому компилятор или аппаратура не должны переупорядочивать, дублировать или исключать их.
    • Если вы записываете, а затем читаете, например через изменяемую ссылку, компилятор может предположить, что прочитанное значение совпадает с только что записанным, и не станет на самом деле читать память.
  • Некоторые существующие крейты для энергозависимого доступа к аппаратуре хранят ссылки, но это некорректно (unsound). Всякий раз, когда существует ссылка, компилятор может решить её разыменовать.
  • Используйте &raw, чтобы получить указатели на поля структуры из указателя на структуру.
  • Для совместимости со старыми версиями Rust можно использовать макрос addr_of!.

Напишем драйвер UART

В машине QEMU ‘virt’ есть UART PL011, поэтому напишем для него драйвер.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

const FLAG_REGISTER_OFFSET: usize = 0x18;
const FR_BUSY: u8 = 1 << 3;
const FR_TXFF: u8 = 1 << 5;

/// Минимальный драйвер для UART PL011.
#[derive(Debug)]
pub struct Uart {
    base_address: *mut u8,
}

impl Uart {
    /// Создаёт новый экземпляр драйвера UART для устройства PL011 по
    /// заданному базовому адресу.
    ///
    /// # Safety
    ///
    /// Заданный базовый адрес должен указывать на 8 управляющих регистров MMIO
    /// устройства PL011, которые должны быть отображены в адресное пространство процесса
    /// как память устройства и не иметь других алиасов.
    pub unsafe fn new(base_address: *mut u8) -> Self {
        Self { base_address }
    }

    /// Записывает один байт в UART.
    pub fn write_byte(&self, byte: u8) {
        // Ждём, пока в буфере передачи (TX) не появится место.
        while self.read_flag_register() & FR_TXFF != 0 {}

        // SAFETY: Мы знаем, что базовый адрес указывает на управляющие
        // регистры устройства PL011, которое корректно отображено.
        unsafe {
            // Записываем в буфер передачи (TX).
            self.base_address.write_volatile(byte);
        }

        // Ждём, пока UART больше не занят.
        while self.read_flag_register() & FR_BUSY != 0 {}
    }

    fn read_flag_register(&self) -> u8 {
        // SAFETY: Мы знаем, что базовый адрес указывает на управляющие
        // регистры устройства PL011, которое корректно отображено.
        unsafe { self.base_address.add(FLAG_REGISTER_OFFSET).read_volatile() }
    }
}
  • Обратите внимание, что Uart::new небезопасна, а остальные методы безопасны. Это потому, что если вызывающий Uart::new гарантирует выполнение её требований безопасности (то есть что для данного UART существует только один экземпляр драйвера и ничто другое не использует его адресное пространство через алиасы), то write_byte всегда безопасно вызывать позже, поскольку мы можем считать необходимые предусловия выполненными.
  • Можно было бы сделать наоборот (сделать new безопасной, а write_byte небезопасной), но тогда использовать это было бы гораздо неудобнее, так как каждое место, где вызывается write_byte, пришлось бы обосновывать безопасность
  • Это распространённый паттерн для написания безопасных обёрток над небезопасным кодом: перенос бремени доказательства корректности (soundness) с большого числа мест на меньшее число мест.

Ещё трейты

Мы вывели (derive) трейт Debug. Было бы полезно реализовать и несколько других трейтов.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use core::fmt::{self, Write};

impl Write for Uart {
    fn write_str(&mut self, s: &str) -> fmt::Result {
        for c in s.as_bytes() {
            self.write_byte(*c);
        }
        Ok(())
    }
}

// SAFETY: `Uart` содержит только указатель на память устройства, к которой
// можно обратиться из любого контекста.
unsafe impl Send for Uart {}
  • Реализация Write позволяет использовать макросы write! и writeln! с нашим типом Uart.

  • Send — авто-трейт, но он не реализуется автоматически, потому что для указателей он не реализован.

Использование

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

#![no_main]
#![no_std]

mod asm;
mod exceptions;
mod pl011_minimal;

use crate::pl011_minimal::Uart;
use core::fmt::Write;
use core::panic::PanicInfo;
use log::error;
use smccc::Hvc;
use smccc::psci::system_off;

/// Базовый адрес основного UART PL011.
const PL011_BASE_ADDRESS: *mut u8 = 0x900_0000 as _;

// SAFETY: Нет другой глобальной функции с таким именем.
#[unsafe(no_mangle)]
extern "C" fn main(x0: u64, x1: u64, x2: u64, x3: u64) {
    // SAFETY: `PL011_BASE_ADDRESS` --- базовый адрес устройства PL011, и
    // никто другой не обращается к этому диапазону адресов.
    let mut uart = unsafe { Uart::new(PL011_BASE_ADDRESS) };

    writeln!(uart, "main({x0:#x}, {x1:#x}, {x2:#x}, {x3:#x})").unwrap();

    system_off::<Hvc>().unwrap();
}
  • Как и в примере с встроенным ассемблером, эта функция main вызывается из нашего кода точки входа в entry.S. Подробности см. в заметках докладчика там.
  • Запустите пример в QEMU командой make qemu_minimal в каталоге src/bare-metal/aps/examples.

Более совершенный драйвер UART

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

СмещениеИмя регистраШирина
0x00DR12
0x04RSR4
0x18FR9
0x20ILPR8
0x24IBRD16
0x28FBRD6
0x2cLCR_H8
0x30CR16
0x34IFLS6
0x38IMSC11
0x3cRIS11
0x40MIS11
0x44ICR11
0x48DMACR3
  • Некоторые регистры ID для краткости опущены.

Bitflags

Крейт bitflags удобен для работы с битовыми флагами.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use bitflags::bitflags;

bitflags! {
    /// Флаги из регистра флагов UART.
    #[repr(transparent)]
    #[derive(Copy, Clone, Debug, Eq, PartialEq)]
    struct Flags: u16 {
        /// Clear to send (готовность к отправке).
        const CTS = 1 << 0;
        /// Data set ready (готовность набора данных).
        const DSR = 1 << 1;
        /// Data carrier detect (обнаружение несущей).
        const DCD = 1 << 2;
        /// UART занят передачей данных.
        const BUSY = 1 << 3;
        /// Очередь приёма (FIFO) пуста.
        const RXFE = 1 << 4;
        /// Очередь передачи (FIFO) заполнена.
        const TXFF = 1 << 5;
        /// Очередь приёма (FIFO) заполнена.
        const RXFF = 1 << 6;
        /// Очередь передачи (FIFO) пуста.
        const TXFE = 1 << 7;
        /// Индикатор кольца (ring indicator).
        const RI = 1 << 8;
    }
}
  • Макрос bitflags! создаёт новый тип-обёртку примерно вида struct Flags(u16) и набор реализаций методов для чтения и установки флагов.

Несколько регистров

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

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[repr(C, align(4))]
pub struct Registers {
    dr: u16,
    _reserved0: [u8; 2],
    rsr: ReceiveStatus,
    _reserved1: [u8; 19],
    fr: Flags,
    _reserved2: [u8; 6],
    ilpr: u8,
    _reserved3: [u8; 3],
    ibrd: u16,
    _reserved4: [u8; 2],
    fbrd: u8,
    _reserved5: [u8; 3],
    lcr_h: u8,
    _reserved6: [u8; 3],
    cr: u16,
    _reserved7: [u8; 3],
    ifls: u8,
    _reserved8: [u8; 3],
    imsc: u16,
    _reserved9: [u8; 2],
    ris: u16,
    _reserved10: [u8; 2],
    mis: u16,
    _reserved11: [u8; 2],
    icr: u16,
    _reserved12: [u8; 2],
    dmacr: u8,
    _reserved13: [u8; 3],
}
  • #[repr(C)] указывает компилятору расположить поля структуры по порядку по тем же правилам, что и C. Это необходимо, чтобы наша структура имела предсказуемую раскладку, поскольку стандартное представление Rust позволяет компилятору (среди прочего) переставлять поля как ему угодно.

Драйвер

Теперь используем новую структуру Registers в нашем драйвере.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Драйвер для UART PL011.
#[derive(Debug)]
pub struct Uart {
    registers: *mut Registers,
}

impl Uart {
    /// Создаёт новый экземпляр драйвера UART для устройства PL011 с
    /// заданным набором регистров.
    ///
    /// # Safety
    ///
    /// Заданный указатель должен указывать на 8 управляющих регистров MMIO
    /// устройства PL011, которые должны быть отображены в адресное пространство процесса как
    /// память устройства и не иметь других алиасов.
    pub unsafe fn new(registers: *mut Registers) -> Self {
        Self { registers }
    }

    /// Записывает один байт в UART.
    pub fn write_byte(&mut self, byte: u8) {
        // Ждём, пока в буфере передачи (TX) не появится место.
        while self.read_flag_register().contains(Flags::TXFF) {}

        // SAFETY: Мы знаем, что self.registers указывает на управляющие регистры
        // устройства PL011, которое корректно отображено.
        unsafe {
            // Записываем в буфер передачи (TX).
            (&raw mut (*self.registers).dr).write_volatile(byte.into());
        }

        // Ждём, пока UART больше не занят.
        while self.read_flag_register().contains(Flags::BUSY) {}
    }

    /// Читает и возвращает ожидающий байт или `None`, если ничего
    /// не получено.
    pub fn read_byte(&mut self) -> Option<u8> {
        if self.read_flag_register().contains(Flags::RXFE) {
            None
        } else {
            // SAFETY: Мы знаем, что self.registers указывает на управляющие
            // регистры устройства PL011, которое корректно отображено.
            let data = unsafe { (&raw const (*self.registers).dr).read_volatile() };
            // TODO: Проверить условия ошибок в битах 8–11.
            Some(data as u8)
        }
    }

    fn read_flag_register(&self) -> Flags {
        // SAFETY: Мы знаем, что self.registers указывает на управляющие регистры
        // устройства PL011, которое корректно отображено.
        unsafe { (&raw const (*self.registers).fr).read_volatile() }
    }
}
  • Обратите внимание на использование &raw const / &raw mut, чтобы получить указатели на отдельные поля без создания промежуточной ссылки, что было бы некорректно.
  • Пример не включён в слайды, потому что он очень похож на пример safe-mmio, который идёт следующим. При необходимости его можно запустить в QEMU командой make qemu в каталоге src/bare-metal/aps/examples.

safe-mmio

Крейт safe-mmio предоставляет типы для обёртки регистров, которые можно безопасно читать или записывать.

Нельзя читатьЧтение без побочных эффектовЧтение с побочными эффектами
Нельзя записыватьReadPureReadOnly
Можно записыватьWriteOnlyReadPureWriteReadWrite
// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use safe_mmio::fields::{ReadPure, ReadPureWrite, ReadWrite, WriteOnly};

#[repr(C, align(4))]
pub struct Registers {
    dr: ReadWrite<u16>,
    _reserved0: [u8; 2],
    rsr: ReadPure<ReceiveStatus>,
    _reserved1: [u8; 19],
    fr: ReadPure<Flags>,
    _reserved2: [u8; 6],
    ilpr: ReadPureWrite<u8>,
    _reserved3: [u8; 3],
    ibrd: ReadPureWrite<u16>,
    _reserved4: [u8; 2],
    fbrd: ReadPureWrite<u8>,
    _reserved5: [u8; 3],
    lcr_h: ReadPureWrite<u8>,
    _reserved6: [u8; 3],
    cr: ReadPureWrite<u16>,
    _reserved7: [u8; 3],
    ifls: ReadPureWrite<u8>,
    _reserved8: [u8; 3],
    imsc: ReadPureWrite<u16>,
    _reserved9: [u8; 2],
    ris: ReadPure<u16>,
    _reserved10: [u8; 2],
    mis: ReadPure<u16>,
    _reserved11: [u8; 2],
    icr: WriteOnly<u16>,
    _reserved12: [u8; 2],
    dmacr: ReadPureWrite<u8>,
    _reserved13: [u8; 3],
}
  • Чтение dr имеет побочный эффект: оно извлекает байт из очереди приёма (FIFO).
  • Чтение rsr (и других регистров) не имеет побочных эффектов. Это «чистое» (pure) чтение.
  • Существует несколько разных крейтов, которые предоставляют безопасные абстракции над операциями MMIO; мы рекомендуем крейт safe-mmio.
  • Разница между ReadPure или ReadOnly (и аналогично между ReadPureWrite и ReadWrite) в том, может ли чтение регистра иметь побочные эффекты, которые меняют состояние устройства, например чтение регистра данных извлекает байт из очереди приёма. ReadPure означает, что чтение не имеет побочных эффектов, это чистое чтение данных.

Драйвер

Теперь используем новую структуру Registers в нашем драйвере.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use safe_mmio::{UniqueMmioPointer, field, field_shared};

/// Драйвер для UART PL011.
#[derive(Debug)]
pub struct Uart<'a> {
    registers: UniqueMmioPointer<'a, Registers>,
}

impl<'a> Uart<'a> {
    /// Создаёт новый экземпляр драйвера UART для устройства PL011 с
    /// заданным набором регистров.
    pub fn new(registers: UniqueMmioPointer<'a, Registers>) -> Self {
        Self { registers }
    }

    /// Записывает один байт в UART.
    pub fn write_byte(&mut self, byte: u8) {
        // Ждём, пока в буфере передачи (TX) не появится место.
        while self.read_flag_register().contains(Flags::TXFF) {}

        // Записываем в буфер передачи (TX).
        field!(self.registers, dr).write(byte.into());

        // Ждём, пока UART больше не занят.
        while self.read_flag_register().contains(Flags::BUSY) {}
    }

    /// Читает и возвращает ожидающий байт или `None`, если ничего
    /// не получено.
    pub fn read_byte(&mut self) -> Option<u8> {
        if self.read_flag_register().contains(Flags::RXFE) {
            None
        } else {
            let data = field!(self.registers, dr).read();
            // TODO: Проверить условия ошибок в битах 8–11.
            Some(data as u8)
        }
    }

    fn read_flag_register(&self) -> Flags {
        field_shared!(self.registers, fr).read()
    }
}
  • Драйверу больше не нужен никакой небезопасный код!
  • UniqueMmioPointer — обёртка вокруг сырого указателя на устройство MMIO или регистр. Вызывающий UniqueMmioPointer::new обещает, что указатель допустим и уникален на заданное время жизни, поэтому можно предоставить безопасные методы для чтения и записи полей.
  • Обратите внимание, что Uart::new теперь безопасна; вместо этого небезопасна UniqueMmioPointer::new.
  • Эти обращения MMIO обычно являются обёрткой над read_volatile и write_volatile, хотя на aarch64 они реализованы на ассемблере, чтобы обойти ошибку, из-за которой компилятор может выдавать инструкции, мешающие виртуализации MMIO.
  • Макросы field! и field_shared! внутри используют &raw mut и &raw const, чтобы получить указатели на отдельные поля без создания промежуточной ссылки, что было бы некорректно.
  • field! требует изменяемую ссылку на UniqueMmioPointer и возвращает UniqueMmioPointer, который позволяет читать с побочными эффектами и записывать.
  • field_shared! работает с разделяемой ссылкой либо на UniqueMmioPointer, либо на SharedMmioPointer. Он возвращает SharedMmioPointer, который позволяет только чистое чтение.

Использование

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

#![no_main]
#![no_std]

mod asm;
mod exceptions;
mod pl011;

use crate::pl011::Uart;
use core::fmt::Write;
use core::panic::PanicInfo;
use core::ptr::NonNull;
use log::error;
use safe_mmio::UniqueMmioPointer;
use smccc::Hvc;
use smccc::psci::system_off;

/// Базовый адрес основного UART PL011.
const PL011_BASE_ADDRESS: NonNull<pl011::Registers> =
    NonNull::new(0x900_0000 as _).unwrap();

// SAFETY: Нет другой глобальной функции с таким именем.
#[unsafe(no_mangle)]
extern "C" fn main(x0: u64, x1: u64, x2: u64, x3: u64) {
    // SAFETY: `PL011_BASE_ADDRESS` --- базовый адрес устройства PL011, и
    // никто другой не обращается к этому диапазону адресов.
    let mut uart = Uart::new(unsafe { UniqueMmioPointer::new(PL011_BASE_ADDRESS) });

    writeln!(uart, "main({x0:#x}, {x1:#x}, {x2:#x}, {x3:#x})").unwrap();

    loop {
        if let Some(byte) = uart.read_byte() {
            uart.write_byte(byte);
            match byte {
                b'\r' => uart.write_byte(b'\n'),
                b'q' => break,
                _ => continue,
            }
        }
    }

    writeln!(uart, "\n\nBye!").unwrap();
    system_off::<Hvc>().unwrap();
}
  • Запустите пример в QEMU командой make qemu_safemmio в каталоге src/bare-metal/aps/examples.

Логирование

Было бы удобно использовать макросы логирования из крейта log. Это можно сделать, реализовав трейт Log.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use crate::pl011::Uart;
use core::fmt::Write;
use log::{LevelFilter, Log, Metadata, Record, SetLoggerError};
use spin::mutex::SpinMutex;

static LOGGER: Logger = Logger { uart: SpinMutex::new(None) };

struct Logger {
    uart: SpinMutex<Option<Uart<'static>>>,
}

impl Log for Logger {
    fn enabled(&self, _metadata: &Metadata) -> bool {
        true
    }

    fn log(&self, record: &Record) {
        writeln!(
            self.uart.lock().as_mut().unwrap(),
            "[{}] {}",
            record.level(),
            record.args()
        )
        .unwrap();
    }

    fn flush(&self) {}
}

/// Инициализирует логгер UART.
pub fn init(
    uart: Uart<'static>,
    max_level: LevelFilter,
) -> Result<(), SetLoggerError> {
    LOGGER.uart.lock().replace(uart);

    log::set_logger(&LOGGER)?;
    log::set_max_level(max_level);
    Ok(())
}
  • Первый unwrap в log выполнится успешно, потому что мы инициализируем LOGGER до вызова set_logger. Второй выполнится успешно, потому что Uart::write_str всегда возвращает Ok.

Использование

Нужно инициализировать логгер до его использования.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#![no_main]
#![no_std]

mod asm;
mod exceptions;
mod logger;
mod pl011;

use crate::pl011::Uart;
use core::panic::PanicInfo;
use core::ptr::NonNull;
use log::{LevelFilter, error, info};
use safe_mmio::UniqueMmioPointer;
use smccc::Hvc;
use smccc::psci::system_off;

/// Базовый адрес основного UART PL011.
const PL011_BASE_ADDRESS: NonNull<pl011::Registers> =
    NonNull::new(0x900_0000 as _).unwrap();

// SAFETY: Нет другой глобальной функции с таким именем.
#[unsafe(no_mangle)]
extern "C" fn main(x0: u64, x1: u64, x2: u64, x3: u64) {
    // SAFETY: `PL011_BASE_ADDRESS` --- базовый адрес устройства PL011, и
    // никто другой не обращается к этому диапазону адресов.
    let uart = unsafe { Uart::new(UniqueMmioPointer::new(PL011_BASE_ADDRESS)) };
    logger::init(uart, LevelFilter::Trace).unwrap();

    info!("main({x0:#x}, {x1:#x}, {x2:#x}, {x3:#x})");

    assert_eq!(x1, 42);

    system_off::<Hvc>().unwrap();
}

#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    error!("{info}");
    system_off::<Hvc>().unwrap();
    loop {}
}
  • Обратите внимание, что наш обработчик паники теперь может логировать подробности паник.
  • Запустите пример в QEMU командой make qemu_logger в каталоге src/bare-metal/aps/examples.

Исключения

AArch64 определяет таблицу векторов исключений с 16 записями для 4 типов исключений (синхронное, IRQ, FIQ, SError) из 4 состояний (текущий EL с SP0, текущий EL с SPx, более низкий EL в AArch64, более низкий EL в AArch32). Мы реализуем её на ассемблере, чтобы сохранять энергозависимые регистры в стеке перед вызовом кода Rust:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use log::error;
use smccc::Hvc;
use smccc::psci::system_off;

// SAFETY: There is no other global function of this name.
#[unsafe(no_mangle)]
extern "C" fn sync_current(_elr: u64, _spsr: u64) {
    error!("sync_current");
    system_off::<Hvc>().unwrap();
}

// SAFETY: There is no other global function of this name.
#[unsafe(no_mangle)]
extern "C" fn irq_current(_elr: u64, _spsr: u64) {
    error!("irq_current");
    system_off::<Hvc>().unwrap();
}

// SAFETY: There is no other global function of this name.
#[unsafe(no_mangle)]
extern "C" fn fiq_current(_elr: u64, _spsr: u64) {
    error!("fiq_current");
    system_off::<Hvc>().unwrap();
}

// SAFETY: There is no other global function of this name.
#[unsafe(no_mangle)]
extern "C" fn serror_current(_elr: u64, _spsr: u64) {
    error!("serror_current");
    system_off::<Hvc>().unwrap();
}

// SAFETY: There is no other global function of this name.
#[unsafe(no_mangle)]
extern "C" fn sync_lower(_elr: u64, _spsr: u64) {
    error!("sync_lower");
    system_off::<Hvc>().unwrap();
}

// SAFETY: There is no other global function of this name.
#[unsafe(no_mangle)]
extern "C" fn irq_lower(_elr: u64, _spsr: u64) {
    error!("irq_lower");
    system_off::<Hvc>().unwrap();
}

// SAFETY: There is no other global function of this name.
#[unsafe(no_mangle)]
extern "C" fn fiq_lower(_elr: u64, _spsr: u64) {
    error!("fiq_lower");
    system_off::<Hvc>().unwrap();
}

// SAFETY: There is no other global function of this name.
#[unsafe(no_mangle)]
extern "C" fn serror_lower(_elr: u64, _spsr: u64) {
    error!("serror_lower");
    system_off::<Hvc>().unwrap();
}
  • EL — уровень исключений; все наши примеры сегодня днём работают в EL1.
  • Для простоты мы не различаем SP0 и SPx для исключений текущего EL, а также AArch32 и AArch64 для исключений более низкого EL.
  • В этом примере мы просто логируем исключение и выключаем питание, потому что не ожидаем, что какое-либо из них действительно произойдёт.
  • Обработчики исключений и наш основной контекст выполнения можно более или менее рассматривать как разные потоки. Send и Sync будут определять, что мы можем разделять между ними, как и для потоков. Например, если мы хотим разделить некоторое значение между обработчиками исключений и остальной программой, и оно Send, но не Sync, нам понадобится обернуть его во что-то вроде Mutex и поместить в static.

Ассемблерный код вектора исключений:

/**
 * Saves the volatile registers onto the stack. This currently takes
 * 14 instructions, so it can be used in exception handlers with 18
 * instructions left.
 *
 * On return, x0 and x1 are initialised to elr_el2 and spsr_el2
 * respectively, which can be used as the first and second arguments
 * of a subsequent call.
 */
.macro save_volatile_to_stack
	/* Reserve stack space and save registers x0-x18, x29 & x30. */
	stp x0, x1, [sp, #-(8 * 24)]!
	stp x2, x3, [sp, #8 * 2]
	stp x4, x5, [sp, #8 * 4]
	stp x6, x7, [sp, #8 * 6]
	stp x8, x9, [sp, #8 * 8]
	stp x10, x11, [sp, #8 * 10]
	stp x12, x13, [sp, #8 * 12]
	stp x14, x15, [sp, #8 * 14]
	stp x16, x17, [sp, #8 * 16]
	str x18, [sp, #8 * 18]
	stp x29, x30, [sp, #8 * 20]

	/*
	 * Save elr_el1 & spsr_el1. This such that we can take nested
	 * exception and still be able to unwind.
	 */
	mrs x0, elr_el1
	mrs x1, spsr_el1
	stp x0, x1, [sp, #8 * 22]
.endm

/**
 * Restores the volatile registers from the stack. This currently
 * takes 14 instructions, so it can be used in exception handlers
 * while still leaving 18 instructions left; if paired with
 * save_volatile_to_stack, there are 4 instructions to spare.
 */
.macro restore_volatile_from_stack
	/* Restore registers x2-x18, x29 & x30. */
	ldp x2, x3, [sp, #8 * 2]
	ldp x4, x5, [sp, #8 * 4]
	ldp x6, x7, [sp, #8 * 6]
	ldp x8, x9, [sp, #8 * 8]
	ldp x10, x11, [sp, #8 * 10]
	ldp x12, x13, [sp, #8 * 12]
	ldp x14, x15, [sp, #8 * 14]
	ldp x16, x17, [sp, #8 * 16]
	ldr x18, [sp, #8 * 18]
	ldp x29, x30, [sp, #8 * 20]

	/*
	 * Restore registers elr_el1 & spsr_el1, using x0 & x1 as scratch.
	 */
	ldp x0, x1, [sp, #8 * 22]
	msr elr_el1, x0
	msr spsr_el1, x1

	/* Restore x0 & x1, and release stack space. */
	ldp x0, x1, [sp], #8 * 24
.endm

/**
 * This is a generic handler for exceptions taken at the current EL. It saves
 * volatile registers to the stack, calls the Rust handler, restores volatile
 * registers, then returns.
 *
 * This also works for exceptions taken from lower ELs, if we don't care about
 * non-volatile registers.
 *
 * Saving state and jumping to the Rust handler takes 15 instructions, and
 * restoring and returning also takes 15 instructions, so we can fit the whole
 * handler in 30 instructions, under the limit of 32.
 */
.macro current_exception handler:req
	save_volatile_to_stack
	bl \handler
	restore_volatile_from_stack
	eret
.endm

.section .text.vector_table_el1, "ax"
.global vector_table_el1
.balign 0x800
vector_table_el1:
sync_cur_sp0:
	current_exception sync_current

.balign 0x80
irq_cur_sp0:
	current_exception irq_current

.balign 0x80
fiq_cur_sp0:
	current_exception fiq_current

.balign 0x80
serr_cur_sp0:
	current_exception serror_current

.balign 0x80
sync_cur_spx:
	current_exception sync_current

.balign 0x80
irq_cur_spx:
	current_exception irq_current

.balign 0x80
fiq_cur_spx:
	current_exception fiq_current

.balign 0x80
serr_cur_spx:
	current_exception serror_current

.balign 0x80
sync_lower_64:
	current_exception sync_lower

.balign 0x80
irq_lower_64:
	current_exception irq_lower

.balign 0x80
fiq_lower_64:
	current_exception fiq_lower

.balign 0x80
serr_lower_64:
	current_exception serror_lower

.balign 0x80
sync_lower_32:
	current_exception sync_lower

.balign 0x80
irq_lower_32:
	current_exception irq_lower

.balign 0x80
fiq_lower_32:
	current_exception fiq_lower

.balign 0x80
serr_lower_32:
	current_exception serror_lower

aarch64-rt

Крейт aarch64-rt предоставляет ассемблерную точку входа и вектор исключений, которые мы реализовали раньше. Нам нужно лишь пометить нашу функцию main макросом entry!.

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

Также можно использовать драйвер UART из крейта arm-pl011-uart, вместо того чтобы писать свой.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

#![no_main]
#![no_std]

mod exceptions_rt;

use aarch64_paging::descriptor::El1Attributes;
use aarch64_rt::{InitialPagetable, entry, initial_pagetable};
use arm_pl011_uart::{PL011Registers, Uart, UniqueMmioPointer};
use core::fmt::Write;
use core::panic::PanicInfo;
use core::ptr::NonNull;
use smccc::Hvc;
use smccc::psci::system_off;

/// Базовый адрес основного UART PL011.
const PL011_BASE_ADDRESS: NonNull<PL011Registers> =
    NonNull::new(0x900_0000 as _).unwrap();

/// Атрибуты, используемые для памяти устройств в начальном тождественном отображении.
const DEVICE_ATTRIBUTES: El1Attributes = El1Attributes::VALID
    .union(El1Attributes::ATTRIBUTE_INDEX_0)
    .union(El1Attributes::ACCESSED)
    .union(El1Attributes::UXN);

/// Атрибуты, используемые для обычной памяти в начальном тождественном отображении.
const MEMORY_ATTRIBUTES: El1Attributes = El1Attributes::VALID
    .union(El1Attributes::ATTRIBUTE_INDEX_1)
    .union(El1Attributes::INNER_SHAREABLE)
    .union(El1Attributes::ACCESSED)
    .union(El1Attributes::NON_GLOBAL);

initial_pagetable!({
    let mut idmap = [0; 512];
    // 1 GiB памяти устройств.
    idmap[0] = DEVICE_ATTRIBUTES.bits();
    // 1 GiB обычной памяти.
    idmap[1] = MEMORY_ATTRIBUTES.bits() | 0x40000000;
    // Ещё 1 GiB памяти устройств, начиная с 256 GiB.
    idmap[256] = DEVICE_ATTRIBUTES.bits() | 0x4000000000;
    InitialPagetable(idmap)
});

entry!(main);
fn main(x0: u64, x1: u64, x2: u64, x3: u64) -> ! {
    // SAFETY: `PL011_BASE_ADDRESS` --- базовый адрес устройства PL011, и
    // никто другой не обращается к этому диапазону адресов.
    let mut uart = unsafe { Uart::new(UniqueMmioPointer::new(PL011_BASE_ADDRESS)) };

    writeln!(uart, "main({x0:#x}, {x1:#x}, {x2:#x}, {x3:#x})").unwrap();

    system_off::<Hvc>().unwrap();
    panic!("system_off returned");
}

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    system_off::<Hvc>().unwrap();
    loop {}
}
  • Запустите пример в QEMU командой make qemu_rt в каталоге src/bare-metal/aps/examples.

Исключения

aarch64-rt предоставляет трейт для определения обработчиков исключений и макрос для генерации ассемблерного кода вектора исключений, который их вызывает.

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

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

use aarch64_rt::{ExceptionHandlers, RegisterStateRef, exception_handlers};
use log::error;
use smccc::Hvc;
use smccc::psci::system_off;

struct Handlers;

impl ExceptionHandlers for Handlers {
    extern "C" fn sync_current(_state: RegisterStateRef) {
        error!("sync_current");
        system_off::<Hvc>().unwrap();
    }

    extern "C" fn irq_current(_state: RegisterStateRef) {
        error!("irq_current");
        system_off::<Hvc>().unwrap();
    }

    extern "C" fn fiq_current(_state: RegisterStateRef) {
        error!("fiq_current");
        system_off::<Hvc>().unwrap();
    }

    extern "C" fn serror_current(_state: RegisterStateRef) {
        error!("serror_current");
        system_off::<Hvc>().unwrap();
    }
}

exception_handlers!(Handlers);
  • Макрос exception_handlers генерирует блок global_asm! с вектором исключений, который вызывает код Rust, аналогично нашему прежнему exceptions.S.
  • RegisterStateRef оборачивает ссылку на кадр стека, где ассемблерный код сохранил значения регистров при возникновении исключения. Это можно использовать, например, чтобы извлечь параметры вызова SMC или HVC из более низкого EL и обновить значения, которые будут восстановлены при возврате из обработчика исключений.

Другие проекты

  • oreboot
    • “coreboot без C”.
    • Поддерживает x86, aarch64 и RISC-V.
    • Опирается на LinuxBoot, а не содержит много драйверов сам.
  • Руководство по Rust для RaspberryPi OS
    • Инициализация, драйвер UART, простой загрузчик, JTAG, уровни исключений, обработка исключений, таблицы страниц.
    • Некоторые оговорки относительно обслуживания кэша и инициализации в Rust; это не обязательно хороший пример для копирования в рабочий код.
  • cargo-call-stack
    • Статический анализ для определения максимального использования стека.
  • Руководство по RaspberryPi OS запускает код Rust до включения MMU и кэшей. Это будет читать и записывать память (например, стек). Однако это имеет проблемы, упомянутые в начале этой сессии, касательно невыровненного доступа и когерентности кэша.

Полезные крейты

Рассмотрим несколько крейтов, которые решают некоторые распространённые задачи в bare-metal программировании.

zerocopy

Крейт zerocopy (из Fuchsia) предоставляет трейты и макросы для безопасного преобразования между последовательностями байтов и другими типами.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use zerocopy::{Immutable, IntoBytes};

#[repr(u32)]
#[derive(Debug, Default, Immutable, IntoBytes)]
enum RequestType {
    #[default]
    In = 0,
    Out = 1,
    Flush = 4,
}

#[repr(C)]
#[derive(Debug, Default, Immutable, IntoBytes)]
struct VirtioBlockRequest {
    request_type: RequestType,
    reserved: u32,
    sector: u64,
}

fn main() {
    let request = VirtioBlockRequest {
        request_type: RequestType::Flush,
        sector: 42,
        ..Default::default()
    };

    assert_eq!(
        request.as_bytes(),
        &[4, 0, 0, 0, 0, 0, 0, 0, 42, 0, 0, 0, 0, 0, 0, 0]
    );
}

Он не подходит для MMIO (поскольку не использует энергозависимое чтение и запись), но может быть полезен при работе со структурами, разделяемыми с аппаратурой, например через DMA, или передаваемыми по какому-либо внешнему интерфейсу.

  • FromBytes можно реализовать для типов, для которых допустима любая битовая комбинация, поэтому их можно безопасно преобразовать из недоверенной последовательности байтов.
  • Попытка вывести FromBytes для этих типов не удастся, потому что RequestType не использует все возможные значения u32 в качестве дискриминантов, поэтому не все битовые комбинации допустимы.
  • В zerocopy::byteorder есть типы для числовых примитивов с учётом порядка байтов.
  • Запустите пример командой cargo run в каталоге src/bare-metal/useful-crates/zerocopy-example/. (В Playground он не запустится из-за зависимости от крейта.)

aarch64-paging

Крейт aarch64-paging позволяет создавать таблицы страниц в соответствии с AArch64 Virtual Memory System Architecture.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use aarch64_paging::{
    idmap::IdMap,
    paging::{Attributes, MemoryRegion},
};

const ASID: usize = 1;
const ROOT_LEVEL: usize = 1;

// Создаём новую таблицу страниц с тождественным отображением.
let mut idmap = IdMap::new(ASID, ROOT_LEVEL);
// Отображаем область памяти 2 МиБ как только для чтения.
idmap.map_range(
    &MemoryRegion::new(0x80200000, 0x80400000),
    Attributes::NORMAL | Attributes::NON_GLOBAL | Attributes::READ_ONLY,
).unwrap();
// Устанавливаем `TTBR0_EL1`, чтобы активировать таблицу страниц.
idmap.activate();
  • Это используется в Android для Protected VM Firmware.
  • Нет простого способа запустить этот пример отдельно, так как он должен работать на реальном оборудовании или под QEMU.

buddy_system_allocator

buddy_system_allocator — крейт, реализующий простой аллокатор «системы двойников». Его можно использовать как для реализации GlobalAlloc (с помощью LockedHeap), чтобы использовать стандартный крейт alloc (как мы видели ранее), так и для выделения другого адресного пространства (с помощью FrameAllocator). Например, нам может понадобиться выделить пространство MMIO для BAR PCI:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use buddy_system_allocator::FrameAllocator;
use core::alloc::Layout;

fn main() {
    let mut allocator = FrameAllocator::<32>::new();
    allocator.add_frame(0x200_0000, 0x400_0000);

    let layout = Layout::from_size_align(0x100, 0x100).unwrap();
    let bar = allocator
        .alloc_aligned(layout)
        .expect("Failed to allocate 0x100 byte MMIO region");
    println!("Allocated 0x100 byte MMIO region at {:#x}", bar);
}
  • Выравнивание BAR PCI всегда равно их размеру.
  • Запустите пример командой cargo run в каталоге src/bare-metal/useful-crates/allocator-example/. (В Playground он не запустится из-за зависимости от крейта.)

tinyvec

Иногда нужно что-то, что можно изменять по размеру, как Vec, но без выделения памяти в куче. tinyvec предоставляет это: вектор, основанный на массиве или срезе (которые могут быть выделены статически или на стеке), который отслеживает, сколько элементов используется, и вызывает панику, если вы пытаетесь использовать больше, чем выделено.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use tinyvec::{ArrayVec, array_vec};

fn main() {
    let mut numbers: ArrayVec<[u32; 5]> = array_vec!(42, 66);
    println!("{numbers:?}");
    numbers.push(7);
    println!("{numbers:?}");
    numbers.remove(1);
    println!("{numbers:?}");
}
  • tinyvec требует, чтобы тип элемента реализовывал Default для инициализации.
  • Rust Playground включает tinyvec, поэтому этот пример будет работать прямо в браузере.

spin

std::sync::Mutex и другие примитивы синхронизации из std::sync недоступны в core или alloc. Как управлять синхронизацией или внутренней изменяемостью, например для совместного использования состояния между разными ЦП?

Крейт spin предоставляет эквиваленты многих из этих примитивов на основе спинлоков.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

use spin::mutex::SpinMutex;

static COUNTER: SpinMutex<u32> = SpinMutex::new(0);

fn main() {
    dbg!(COUNTER.lock());
    *COUNTER.lock() += 2;
    dbg!(COUNTER.lock());
}
  • Старайтесь избегать взаимных блокировок (deadlock), если берёте блокировки в обработчиках прерываний.
  • spin также содержит реализацию мьютекса с ticket lock; эквиваленты RwLock, Barrier и Once из std::sync; а также Lazy для ленивой инициализации.
  • Крейт once_cell также содержит полезные типы для поздней инициализации с немного другим подходом, чем spin::once::Once.
  • Rust Playground включает spin, поэтому этот пример будет работать прямо в браузере.

Bare-metal на Android

Чтобы собрать bare-metal бинарный файл Rust в AOSP, нужно использовать правило Soong rust_ffi_static для сборки кода Rust, затем cc_binary со скриптом компоновщика, чтобы получить сам бинарный файл, и затем raw_binary, чтобы преобразовать ELF в сырой бинарный файл, готовый к запуску.

rust_ffi_static {
    name: "libvmbase_example",
    defaults: ["vmbase_ffi_defaults"],
    crate_name: "vmbase_example",
    srcs: ["src/main.rs"],
    rustlibs: [
        "libvmbase",
    ],
}

cc_binary {
    name: "vmbase_example",
    defaults: ["vmbase_elf_defaults"],
    srcs: [
        "idmap.S",
    ],
    static_libs: [
        "libvmbase_example",
    ],
    linker_scripts: [
        "image.ld",
        ":vmbase_sections",
    ],
}

raw_binary {
    name: "vmbase_example_bin",
    stem: "vmbase_example.bin",
    src: ":vmbase_example",
    enabled: false,
    target: {
        android_arm64: {
            enabled: true,
        },
    },
}

vmbase

Для виртуальных машин, работающих под crosvm на aarch64, библиотека vmbase предоставляет скрипт компоновщика и полезные значения по умолчанию для правил сборки, а также точку входа, логирование в консоль UART и многое другое.

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#![no_main]
#![no_std]

use vmbase::{main, println};

main!(main);

pub fn main(arg0: u64, arg1: u64, arg2: u64, arg3: u64) {
    println!("Hello world");
}
  • Макрос main! отмечает вашу функцию main, которая будет вызвана из точки входа vmbase.
  • Точка входа vmbase выполняет инициализацию консоли и выдаёт PSCI_SYSTEM_OFF, чтобы выключить ВМ, если ваша функция main вернёт управление.

Упражнения

Мы напишем драйвер для устройства часов реального времени PL031.

Ознакомившись с упражнениями, вы можете посмотреть предложенные решения.

Драйвер RTC

В машине QEMU aarch64 virt есть часы реального времени PL031 по адресу 0x9010000. Для этого упражнения вам нужно написать для них драйвер.

  1. Используйте его, чтобы вывести текущее время в последовательную консоль. Для форматирования даты и времени можно использовать крейт chrono.
  2. Используйте регистр сравнения (match) и сырой статус прерываний, чтобы ждать занятым циклом (busy-wait) до заданного времени, например 3 секунды в будущем. (Вызовите core::hint::spin_loop внутри цикла.)
  3. Расширение, если останется время: включите и обработайте прерывание, которое генерирует совпадение RTC. Для настройки Arm Generic Interrupt Controller можно использовать драйвер из крейта arm-gic.
    • Используйте прерывание RTC, которое подключено к GIC как IntId::spi(2).
    • После включения прерывания можно усыпить ядро с помощью arm_gic::wfi(), что приведёт к тому, что ядро заснёт, пока не получит прерывание.

Скачайте шаблон упражнения и найдите в каталоге rtc следующие файлы.

src/main.rs:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#![no_main]
#![no_std]

mod exceptions;
mod logger;

use aarch64_paging::descriptor::El1Attributes;
use aarch64_rt::{InitialPagetable, entry, initial_pagetable};
use arm_gic::gicv3::registers::{Gicd, GicrSgi};
use arm_gic::gicv3::{GicCpuInterface, GicV3};
use arm_pl011_uart::{PL011Registers, Uart};
use core::panic::PanicInfo;
use core::ptr::NonNull;
use log::{LevelFilter, error, info, trace};
use safe_mmio::UniqueMmioPointer;
use smccc::Hvc;
use smccc::psci::system_off;

/// Base addresses of the GICv3.
const GICD_BASE_ADDRESS: NonNull<Gicd> = NonNull::new(0x800_0000 as _).unwrap();
const GICR_BASE_ADDRESS: NonNull<GicrSgi> = NonNull::new(0x80A_0000 as _).unwrap();

/// Base address of the primary PL011 UART.
const PL011_BASE_ADDRESS: NonNull<PL011Registers> =
    NonNull::new(0x900_0000 as _).unwrap();

/// Attributes to use for device memory in the initial identity map.
const DEVICE_ATTRIBUTES: El1Attributes = El1Attributes::VALID
    .union(El1Attributes::ATTRIBUTE_INDEX_0)
    .union(El1Attributes::ACCESSED)
    .union(El1Attributes::UXN);

/// Attributes to use for normal memory in the initial identity map.
const MEMORY_ATTRIBUTES: El1Attributes = El1Attributes::VALID
    .union(El1Attributes::ATTRIBUTE_INDEX_1)
    .union(El1Attributes::INNER_SHAREABLE)
    .union(El1Attributes::ACCESSED)
    .union(El1Attributes::NON_GLOBAL);

initial_pagetable!({
    let mut idmap = [0; 512];
    // 1 GiB of device memory.
    idmap[0] = DEVICE_ATTRIBUTES.bits();
    // 1 GiB of normal memory.
    idmap[1] = MEMORY_ATTRIBUTES.bits() | 0x40000000;
    // Another 1 GiB of device memory starting at 256 GiB.
    idmap[256] = DEVICE_ATTRIBUTES.bits() | 0x4000000000;
    InitialPagetable(idmap)
});

entry!(main);
fn main(x0: u64, x1: u64, x2: u64, x3: u64) -> ! {
    // SAFETY: `PL011_BASE_ADDRESS` is the base address of a PL011 device, and
    // nothing else accesses that address range.
    let uart = unsafe { Uart::new(UniqueMmioPointer::new(PL011_BASE_ADDRESS)) };
    logger::init(uart, LevelFilter::Trace).unwrap();

    info!("main({:#x}, {:#x}, {:#x}, {:#x})", x0, x1, x2, x3);

    // SAFETY: `GICD_BASE_ADDRESS` and `GICR_BASE_ADDRESS` are the base
    // addresses of a GICv3 distributor and redistributor respectively, and
    // nothing else accesses those address ranges.
    let mut gic = unsafe {
        GicV3::new(UniqueMmioPointer::new(GICD_BASE_ADDRESS), GICR_BASE_ADDRESS, 1)
    }
    .unwrap();
    gic.setup(0);

    // TODO: Create instance of RTC driver and print current time.

    // TODO: Wait for 3 seconds.

    system_off::<Hvc>().unwrap();
    panic!("system_off returned");
}

#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    error!("{info}");
    system_off::<Hvc>().unwrap();
    loop {}
}

src/exceptions.rs (менять его нужно только для третьей части упражнения):

#![allow(unused)]
fn main() {
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use aarch64_rt::{ExceptionHandlers, RegisterStateRef, exception_handlers};
use arm_gic::InterruptGroup;
use arm_gic::gicv3::GicCpuInterface;
use log::{error, info, trace};
use smccc::Hvc;
use smccc::psci::system_off;

struct Handlers;

impl ExceptionHandlers for Handlers {
    extern "C" fn sync_current(_state: RegisterStateRef) {
        error!("sync_current");
        system_off::<Hvc>().unwrap();
    }

    extern "C" fn irq_current(_state: RegisterStateRef) {
        trace!("irq_current");
        let intid =
            GicCpuInterface::get_and_acknowledge_interrupt(InterruptGroup::Group1)
                .expect("No pending interrupt");
        info!("IRQ {intid:?}");
    }

    extern "C" fn fiq_current(_state: RegisterStateRef) {
        error!("fiq_current");
        system_off::<Hvc>().unwrap();
    }

    extern "C" fn serror_current(_state: RegisterStateRef) {
        error!("serror_current");
        system_off::<Hvc>().unwrap();
    }

    extern "C" fn sync_lower(_state: RegisterStateRef) {
        error!("sync_lower");
        system_off::<Hvc>().unwrap();
    }

    extern "C" fn irq_lower(_state: RegisterStateRef) {
        error!("irq_lower");
        system_off::<Hvc>().unwrap();
    }

    extern "C" fn fiq_lower(_state: RegisterStateRef) {
        error!("fiq_lower");
        system_off::<Hvc>().unwrap();
    }

    extern "C" fn serror_lower(_state: RegisterStateRef) {
        error!("serror_lower");
        system_off::<Hvc>().unwrap();
    }
}

exception_handlers!(Handlers);
}

src/logger.rs (менять его, скорее всего, не нужно):

#![allow(unused)]
fn main() {
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use arm_pl011_uart::Uart;
use core::fmt::Write;
use log::{LevelFilter, Log, Metadata, Record, SetLoggerError};
use spin::mutex::SpinMutex;

static LOGGER: Logger = Logger { uart: SpinMutex::new(None) };

struct Logger {
    uart: SpinMutex<Option<Uart<'static>>>,
}

impl Log for Logger {
    fn enabled(&self, _metadata: &Metadata) -> bool {
        true
    }

    fn log(&self, record: &Record) {
        writeln!(
            self.uart.lock().as_mut().unwrap(),
            "[{}] {}",
            record.level(),
            record.args()
        )
        .unwrap();
    }

    fn flush(&self) {}
}

/// Initialises UART logger.
pub fn init(
    uart: Uart<'static>,
    max_level: LevelFilter,
) -> Result<(), SetLoggerError> {
    LOGGER.uart.lock().replace(uart);

    log::set_logger(&LOGGER)?;
    log::set_max_level(max_level);
    Ok(())
}
}

Cargo.toml (менять его, скорее всего, не нужно):

[workspace]

[package]
name = "rtc"
version = "0.1.0"
edition = "2024"
publish = false

[dependencies]
aarch64-paging = { version = "0.12.1", default-features = false }
aarch64-rt = "0.4.3"
arm-gic = "0.10.0"
arm-pl011-uart = "0.5.0"
bitflags = "2.11.1"
chrono = { version = "0.4.44", default-features = false }
log = "0.4.30"
safe-mmio = "0.3.0"
smccc = "0.2.3"
spin = "0.12.0"
zerocopy = "0.8.50"

build.rs (менять его, скорее всего, не нужно):

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

fn main() {
    println!("cargo:rustc-link-arg=-Timage.ld");
    println!("cargo:rustc-link-arg=-Tmemory.ld");
    println!("cargo:rerun-if-changed=memory.ld");
}

memory.ld (менять его, скорее всего, не нужно):

/*
 * Copyright 2023 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

MEMORY
{
	image : ORIGIN = 0x40080000, LENGTH = 2M
}

Makefile (менять его, скорее всего, не нужно):

# Copyright 2023 Google LLC
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

.PHONY: build qemu_minimal qemu qemu_logger

all: rtc.bin

build:
	cargo build

rtc.bin: build
	cargo objcopy -- -O binary $@

qemu: rtc.bin
	qemu-system-aarch64 -machine virt,gic-version=3 -cpu max -serial mon:stdio -display none -kernel $< -s

clean:
	cargo clean
	rm -f *.bin

.cargo/config.toml (менять его, скорее всего, не нужно):

[build]
target = "aarch64-unknown-none"

Запустите код в QEMU командой make qemu.

Bare Metal Rust: вторая половина дня

Драйвер RTC

(назад к упражнению)

main.rs:

// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#![no_main]
#![no_std]

mod exceptions;
mod logger;
mod pl031;

use crate::pl031::Rtc;
use arm_gic::{IntId, Trigger, irq_enable, wfi};
use chrono::{TimeZone, Utc};
use core::hint::spin_loop;
use aarch64_paging::descriptor::El1Attributes;
use aarch64_rt::{InitialPagetable, entry, initial_pagetable};
use arm_gic::gicv3::registers::{Gicd, GicrSgi};
use arm_gic::gicv3::{GicCpuInterface, GicV3};
use arm_pl011_uart::{PL011Registers, Uart};
use core::panic::PanicInfo;
use core::ptr::NonNull;
use log::{LevelFilter, error, info, trace};
use safe_mmio::UniqueMmioPointer;
use smccc::Hvc;
use smccc::psci::system_off;

/// Базовые адреса GICv3.
const GICD_BASE_ADDRESS: NonNull<Gicd> = NonNull::new(0x800_0000 as _).unwrap();
const GICR_BASE_ADDRESS: NonNull<GicrSgi> = NonNull::new(0x80A_0000 as _).unwrap();

/// Базовый адрес основного UART PL011.
const PL011_BASE_ADDRESS: NonNull<PL011Registers> =
    NonNull::new(0x900_0000 as _).unwrap();

/// Атрибуты, используемые для памяти устройств в начальном тождественном отображении.
const DEVICE_ATTRIBUTES: El1Attributes = El1Attributes::VALID
    .union(El1Attributes::ATTRIBUTE_INDEX_0)
    .union(El1Attributes::ACCESSED)
    .union(El1Attributes::UXN);

/// Атрибуты, используемые для обычной памяти в начальном тождественном отображении.
const MEMORY_ATTRIBUTES: El1Attributes = El1Attributes::VALID
    .union(El1Attributes::ATTRIBUTE_INDEX_1)
    .union(El1Attributes::INNER_SHAREABLE)
    .union(El1Attributes::ACCESSED)
    .union(El1Attributes::NON_GLOBAL);

initial_pagetable!({
    let mut idmap = [0; 512];
    // 1 GiB памяти устройств.
    idmap[0] = DEVICE_ATTRIBUTES.bits();
    // 1 GiB обычной памяти.
    idmap[1] = MEMORY_ATTRIBUTES.bits() | 0x40000000;
    // Ещё 1 GiB памяти устройств, начиная с 256 GiB.
    idmap[256] = DEVICE_ATTRIBUTES.bits() | 0x4000000000;
    InitialPagetable(idmap)
});

/// Базовый адрес PL031 RTC.
const PL031_BASE_ADDRESS: NonNull<pl031::Registers> =
    NonNull::new(0x901_0000 as _).unwrap();
/// Прерывание (IRQ), используемое PL031 RTC.
const PL031_IRQ: IntId = IntId::spi(2);

entry!(main);
fn main(x0: u64, x1: u64, x2: u64, x3: u64) -> ! {
    // SAFETY: `PL011_BASE_ADDRESS` --- базовый адрес устройства PL011, и
    // никто другой не обращается к этому диапазону адресов.
    let uart = unsafe { Uart::new(UniqueMmioPointer::new(PL011_BASE_ADDRESS)) };
    logger::init(uart, LevelFilter::Trace).unwrap();

    info!("main({:#x}, {:#x}, {:#x}, {:#x})", x0, x1, x2, x3);

    // SAFETY: `GICD_BASE_ADDRESS` и `GICR_BASE_ADDRESS` --- базовые адреса
    // распределителя и перераспределителя GICv3 соответственно, и
    // никто другой не обращается к этим диапазонам адресов.
    let mut gic = unsafe {
        GicV3::new(UniqueMmioPointer::new(GICD_BASE_ADDRESS), GICR_BASE_ADDRESS, 1)
    }
    .unwrap();
    gic.setup(0);

    // SAFETY: `PL031_BASE_ADDRESS` --- базовый адрес устройства PL031, и
    // никто другой не обращается к этому диапазону адресов.
    let mut rtc = unsafe { Rtc::new(UniqueMmioPointer::new(PL031_BASE_ADDRESS)) };
    let timestamp = rtc.read();
    let time = Utc.timestamp_opt(timestamp.into(), 0).unwrap();
    info!("RTC: {time}");

    GicCpuInterface::set_priority_mask(0xff);
    gic.set_interrupt_priority(PL031_IRQ, None, 0x80).unwrap();
    gic.set_trigger(PL031_IRQ, None, Trigger::Level).unwrap();
    irq_enable();
    gic.enable_interrupt(PL031_IRQ, None, true).unwrap();

    // Ждём 3 секунды без прерываний.
    let target = timestamp + 3;
    rtc.set_match(target);
    info!("Waiting for {}", Utc.timestamp_opt(target.into(), 0).unwrap());
    trace!(
        "matched={}, interrupt_pending={}",
        rtc.matched(),
        rtc.interrupt_pending()
    );
    while !rtc.matched() {
        spin_loop();
    }
    trace!(
        "matched={}, interrupt_pending={}",
        rtc.matched(),
        rtc.interrupt_pending()
    );
    info!("Finished waiting");

    // Ждём ещё 3 секунды прерывания.
    let target = timestamp + 6;
    info!("Waiting for {}", Utc.timestamp_opt(target.into(), 0).unwrap());
    rtc.set_match(target);
    rtc.clear_interrupt();
    rtc.enable_interrupt(true);
    trace!(
        "matched={}, interrupt_pending={}",
        rtc.matched(),
        rtc.interrupt_pending()
    );
    while !rtc.interrupt_pending() {
        wfi();
    }
    trace!(
        "matched={}, interrupt_pending={}",
        rtc.matched(),
        rtc.interrupt_pending()
    );
    info!("Finished waiting");

    system_off::<Hvc>().unwrap();
    panic!("system_off returned");
}

#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    error!("{info}");
    system_off::<Hvc>().unwrap();
    loop {}
}

pl031.rs:

#![allow(unused)]
fn main() {
// Copyright 2023 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[repr(C, align(4))]
pub struct Registers {
    /// Регистр данных
    dr: ReadPure<u32>,
    /// Регистр сравнения (match)
    mr: ReadPureWrite<u32>,
    /// Регистр загрузки (load)
    lr: ReadPureWrite<u32>,
    /// Регистр управления
    cr: ReadPureWrite<u8>,
    _reserved0: [u8; 3],
    /// Регистр установки или сброса маски прерываний
    imsc: ReadPureWrite<u8>,
    _reserved1: [u8; 3],
    /// Сырой статус прерываний
    ris: ReadPure<u8>,
    _reserved2: [u8; 3],
    /// Маскированный статус прерываний
    mis: ReadPure<u8>,
    _reserved3: [u8; 3],
    /// Регистр сброса прерываний
    icr: WriteOnly<u8>,
    _reserved4: [u8; 3],
}

/// Драйвер часов реального времени PL031.
#[derive(Debug)]
pub struct Rtc<'a> {
    registers: UniqueMmioPointer<'a, Registers>,
}

impl<'a> Rtc<'a> {
    /// Создаёт новый экземпляр драйвера RTC для устройства PL031 с
    /// заданным набором регистров.
    pub fn new(registers: UniqueMmioPointer<'a, Registers>) -> Self {
        Self { registers }
    }

    /// Читает текущее значение RTC.
    pub fn read(&self) -> u32 {
        field_shared!(self.registers, dr).read()
    }

    /// Записывает значение сравнения. Когда значение RTC совпадёт с ним, будет
    /// сгенерировано прерывание (если оно включено).
    pub fn set_match(&mut self, value: u32) {
        field!(self.registers, mr).write(value);
    }

    /// Возвращает, совпадает ли регистр сравнения со значением RTC, независимо от того,
    /// включено ли прерывание.
    pub fn matched(&self) -> bool {
        let ris = field_shared!(self.registers, ris).read();
        (ris & 0x01) != 0
    }

    /// Возвращает, есть ли в данный момент ожидающее прерывание.
    ///
    /// Это должно быть истинно тогда и только тогда, когда `matched` возвращает истину и
    /// прерывание замаскировано.
    pub fn interrupt_pending(&self) -> bool {
        let mis = field_shared!(self.registers, mis).read();
        (mis & 0x01) != 0
    }

    /// Устанавливает или сбрасывает маску прерываний.
    ///
    /// Когда маска истинна, прерывание включено; когда ложна, прерывание выключено.
    pub fn enable_interrupt(&mut self, mask: bool) {
        let imsc = if mask { 0x01 } else { 0x00 };
        field!(self.registers, imsc).write(imsc);
    }

    /// Сбрасывает ожидающее прерывание, если оно есть.
    pub fn clear_interrupt(&mut self) {
        field!(self.registers, icr).write(0x01);
    }
}
}

Добро пожаловать в конкурентность в Rust

Rust полностью поддерживает конкурентность с помощью потоков ОС, мьютексов и каналов.

Система типов Rust играет важную роль, делая многие ошибки конкурентности ошибками времени компиляции. Эта идея известна как бесстрашная конкурентность (fearless concurrency), поскольку вы можете полагаться на компилятор, чтобы гарантировать корректность во время выполнения.

Расписание

Включая 10-минутные перерывы, это занятие займёт около 3 часов 20 минут. Оно включает:

СегментДлительность
Потоки30 минут
Каналы20 минут
Send и Sync15 минут
Разделяемое состояние30 минут
Упражнения1 час 10 минут
  • Rust позволяет нам обращаться к инструментам конкурентности ОС: потокам, примитивам синхронизации и т. д.
  • Система типов даёт нам безопасность конкурентности без каких-либо особых возможностей.
  • Те же инструменты, которые помогают с «конкурентным» доступом в одном потоке (например, с вызываемой функцией, которая может изменить аргумент или сохранить ссылку на него для последующего чтения), защищают нас от проблем многопоточности.

Потоки

Этот сегмент займёт около 30 минут. Он включает:

СлайдДлительность
Обычные потоки15 минут
Потоки с областью видимости15 минут

Обычные потоки

Потоки Rust работают аналогично потокам в других языках:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 0..10 {
            println!("Count in thread: {i}!");
            thread::sleep(Duration::from_millis(5));
        }
    });

    for i in 0..5 {
        println!("Main thread: {i}");
        thread::sleep(Duration::from_millis(5));
    }
}
  • Создание новых потоков не задерживает автоматически завершение программы в конце main.
  • Паники потоков независимы друг от друга.
    • Паника может нести полезную нагрузку (payload), которую можно извлечь с помощью Any::downcast_ref.
This slide should take about 15 minutes.
  • Запустите пример.

    • Задержки в 5 мс достаточно велики, чтобы main и порождённые потоки в основном шли в ногу.
    • Обратите внимание, что программа завершается до того, как порождённый поток дойдёт до 10!
    • Это потому, что main завершает программу, а порождённые потоки её не продлевают.
      • При желании сравните с pthreads/std::thread в C++/boost::thread.
  • Как дождаться завершения порождённого потока?

  • thread::spawn возвращает JoinHandle. Посмотрите документацию.

    • У JoinHandle есть метод .join(), который блокирует выполнение.
  • Используйте let handle = thread::spawn(...), а затем handle.join(), чтобы дождаться завершения потока и дать программе досчитать до 10.

  • А если мы хотим вернуть значение?

  • Снова посмотрите документацию:

    • Замыкание, переданное в thread::spawn, возвращает T
    • JoinHandle .join() возвращает thread::Result<T>
  • Используйте возвращаемое значение Result из handle.join(), чтобы получить доступ к возвращённому значению.

  • Хорошо, а как насчёт другого случая?

    • Вызовите панику в потоке. Обратите внимание, что это не вызывает панику в main.
    • Получите доступ к полезной нагрузке паники. Сейчас хорошее время поговорить о Any.
  • Теперь мы можем возвращать значения из потоков! А как насчёт передачи входных данных?

    • Захватите что-нибудь по ссылке в замыкании потока.
    • Сообщение об ошибке говорит, что это нужно переместить (move).
    • Переместите значение внутрь и убедитесь, что можно вычислить и затем вернуть производное значение.
  • А если мы хотим заимствовать?

    • main завершает дочерние потоки при возврате, но если бы другая функция просто вернула управление, они продолжили бы работать.
    • Это было бы использование стека после возврата (stack use-after-return), что нарушает безопасность памяти!
    • Как этого избежать? См. следующий слайд.

Потоки с областью видимости

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

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::thread;

fn foo() {
    let s = String::from("Hello");
    thread::spawn(|| {
        dbg!(s.len());
    });
}

fn main() {
    foo();
}

Однако для этого можно использовать область видимости потока (scoped thread):

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::thread;

fn foo() {
    let s = String::from("Hello");
    thread::scope(|scope| {
        scope.spawn(|| {
            dbg!(s.len());
        });
    });
}

fn main() {
    foo();
}
This slide should take about 13 minutes.
  • Причина в том, что когда функция thread::scope завершается, все потоки гарантированно присоединены (joined), поэтому они могут возвращать заимствованные данные.
  • Действуют обычные правила заимствования Rust: либо один поток может заимствовать изменяемо, либо любое число потоков может заимствовать неизменяемо.

Каналы

Этот сегмент займёт около 20 минут. Он включает:

СлайдДлительность
Отправители и получатели10 минут
Неограниченные каналы2 минуты
Ограниченные каналы10 минут

Отправители и получатели

У каналов Rust две части: Sender<T> и Receiver<T>. Эти две части связаны через канал, но вы видите только его концы.

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();

    tx.send(10).unwrap();
    tx.send(20).unwrap();

    println!("Received: {:?}", rx.recv());
    println!("Received: {:?}", rx.recv());

    let tx2 = tx.clone();
    tx2.send(30).unwrap();
    println!("Received: {:?}", rx.recv());
}
This slide should take about 9 minutes.
  • mpsc расшифровывается как Multi-Producer, Single-Consumer (много отправителей, один получатель). Sender и SyncSender реализуют Clone (поэтому можно создать несколько отправителей), а Receiver — нет.
  • send() и recv() возвращают Result. Если они возвращают Err, это значит, что парный Sender или Receiver освобождён и канал закрыт.

Неограниченные каналы

Неограниченный асинхронный канал создаётся с помощью mpsc::channel():

// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let thread_id = thread::current().id();
        for i in 0..10 {
            tx.send(format!("Message {i}")).unwrap();
            println!("{thread_id:?}: sent Message {i}");
        }
        println!("{thread_id:?}: done");
    });
    thread::sleep(Duration::from_millis(100));

    for msg in rx {
        println!("Main: got {msg}");
    }
}
This slide should take about 2 minutes.
  • Неограниченный канал выделяет столько памяти, сколько нужно для хранения ожидающих сообщений. Метод send() не блокирует вызывающий поток.
  • Вызов send() завершится с ошибкой (поэтому он возвращает Result), если канал закрыт. Канал закрывается, когда получатель освобождён.

Ограниченные каналы

С ограниченными (синхронными) каналами send() может блокировать текущий поток:

// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::sync_channel(3);

    thread::spawn(move || {
        let thread_id = thread::current().id();
        for i in 0..10 {
            tx.send(format!("Message {i}")).unwrap();
            println!("{thread_id:?}: sent Message {i}");
        }
        println!("{thread_id:?}: done");
    });
    thread::sleep(Duration::from_millis(100));

    for msg in rx {
        println!("Main: got {msg}");
    }
}
This slide should take about 8 minutes.
  • Вызов send() блокирует текущий поток, пока в канале не появится место для нового сообщения. Поток может заблокироваться бесконечно, если никто не читает из канала.
  • Как и у неограниченных каналов, вызов send() завершится с ошибкой, если канал закрыт.
  • Ограниченный канал размером ноль называется «rendezvous channel» (канал рандеву). Каждая отправка блокирует текущий поток, пока другой поток не вызовет recv().

Send и Sync

Этот сегмент займёт около 15 минут. Он включает:

СлайдДлительность
Маркерные трейты2 минуты
Send2 минуты
Sync2 минуты
Примеры10 минут

Маркерные трейты

Как Rust понимает, что нужно запретить разделяемый доступ между потоками? Ответ — в двух трейтах:

  • Send: тип T реализует Send, если безопасно переместить значение T через границу потока.
  • Sync: тип T реализует Sync, если безопасно переместить &T через границу потока.

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

This slide should take about 2 minutes.
  • Эти трейты можно считать маркерами того, что тип обладает определёнными свойствами потокобезопасности.
  • Их можно использовать в обобщённых ограничениях как обычные трейты.

Send

Тип T является Send, если безопасно переместить значение T в другой поток.

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

This slide should take about 2 minutes.

Например, соединение с библиотекой SQLite должно использоваться только из одного потока.

Sync

Тип T является Sync, если безопасно одновременно обращаться к значению T из нескольких потоков.

Точнее, определение такое:

T является Sync тогда и только тогда, когда &T является Send

This slide should take about 2 minutes.

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

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

Примеры

Send + Sync

Большинство типов, с которыми вы сталкиваетесь, реализуют Send + Sync:

  • i8, f32, bool, char, &str, …
  • (T1, T2), [T; N], &[T], struct { x: T }, …
  • String, Option<T>, Vec<T>, Box<T>, …
  • Arc<T>: явно потокобезопасен благодаря атомарному счётчику ссылок.
  • Mutex<T>: явно потокобезопасен благодаря внутренней блокировке.
  • mpsc::Sender<T>: начиная с 1.72.0.
  • AtomicBool, AtomicU8, …: используют специальные атомарные инструкции.

Обобщённые типы обычно реализуют Send + Sync, когда их параметры типа реализуют Send + Sync.

Send + !Sync

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

  • mpsc::Receiver<T>
  • Cell<T>
  • RefCell<T>

!Send + Sync

К этим типам безопасно обращаться (через разделяемые ссылки) из нескольких потоков, но их нельзя переместить в другой поток:

  • MutexGuard<T>: использует примитивы уровня ОС, которые должны быть освобождены в потоке, который их создал. Однако уже заблокированный мьютекс может быть прочитан любым потоком, с которым разделён охранник (если только сам T не !Sync).

!Send + !Sync

Эти типы не потокобезопасны и не могут быть перемещены в другие потоки:

  • Rc<T>: каждый Rc<T> содержит ссылку на RcBox<T>, в котором есть неатомарный счётчик ссылок.
  • *const T, *mut T: Rust считает, что у сырых указателей могут быть особые соображения относительно конкурентности.

Разделяемое состояние

Этот сегмент займёт около 30 минут. Он включает:

СлайдДлительность
Arc5 минут
Mutex15 минут
Пример10 минут

Arc

Arc<T> позволяет разделяемое владение только для чтения через Arc::clone:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::sync::Arc;
use std::thread;

/// Структура, которая выводит, какой поток её освобождает.
#[derive(Debug)]
struct WhereDropped(Vec<i32>);

impl Drop for WhereDropped {
    fn drop(&mut self) {
        println!("Dropped by {:?}", thread::current().id())
    }
}

fn main() {
    let v = Arc::new(WhereDropped(vec![10, 20, 30]));
    let mut handles = Vec::new();
    for i in 0..5 {
        let v = Arc::clone(&v);
        handles.push(thread::spawn(move || {
            // Спим от 0 до 500 мс.
            std::thread::sleep(std::time::Duration::from_millis(500 - i * 100));
            let thread_id = thread::current().id();
            println!("{thread_id:?}: {v:?}");
        }));
    }

    // Теперь только порождённые потоки будут держать клоны `v`.
    drop(v);

    // Когда последний порождённый поток завершится, он освободит содержимое `v`.
    handles.into_iter().for_each(|h| h.join().unwrap());
}
This slide should take about 5 minutes.
  • Arc расшифровывается как «Atomic Reference Counted» — потокобезопасная версия Rc, которая использует атомарные операции.
  • Arc<T> реализует Clone независимо от того, реализует ли его T. Он реализует Send и Sync тогда и только тогда, когда T реализует оба этих трейта.
  • Arc::clone() имеет стоимость выполнения атомарных операций, но после этого использование T ничего не стоит.
  • Остерегайтесь циклов ссылок: Arc не использует сборщик мусора, чтобы их обнаруживать.
    • Здесь может помочь std::sync::Weak.

Mutex

Mutex<T> обеспечивает взаимное исключение и позволяет изменять T через интерфейс только для чтения (это ещё одна форма внутренней изменяемости):

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::sync::Mutex;

fn main() {
    let v = Mutex::new(vec![10, 20, 30]);
    println!("v: {:?}", v.lock().unwrap());

    {
        let mut guard = v.lock().unwrap();
        guard.push(40);
    }

    println!("v: {:?}", v.lock().unwrap());
}

Обратите внимание, что есть blanket-реализация impl<T: Send> Sync for Mutex<T>.

This slide should take about 14 minutes.
  • Mutex в Rust выглядит как коллекция из одного элемента — защищаемых данных.
    • Невозможно забыть взять мьютекс перед доступом к защищаемым данным.
  • Из &Mutex<T> можно получить &mut T, взяв блокировку. MutexGuard гарантирует, что &mut T не переживёт удерживаемую блокировку.
  • Mutex<T> реализует и Send, и Sync тогда и только тогда, когда T реализует Send.
  • Аналог с блокировкой чтения-записи: RwLock.
  • Почему lock() возвращает Result?
    • Если поток, который держал Mutex, запаниковал, Mutex становится «отравленным» (poisoned), чтобы сигнализировать, что защищаемые данные могут находиться в несогласованном состоянии. Вызов lock() у отравленного мьютекса завершается ошибкой PoisonError. Вы всё равно можете вызвать into_inner() у ошибки, чтобы восстановить данные.

Пример

Посмотрим Arc и Mutex в действии:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::thread;
// use std::sync::{Arc, Mutex};

fn main() {
    let v = vec![10, 20, 30];
    let mut handles = Vec::new();
    for i in 0..5 {
        handles.push(thread::spawn(|| {
            v.push(10 * i);
            println!("v: {v:?}");
        }));
    }

    handles.into_iter().for_each(|h| h.join().unwrap());
}
This slide should take about 8 minutes.

Возможное решение:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let v = Arc::new(Mutex::new(vec![10, 20, 30]));
    let mut handles = Vec::new();
    for i in 0..5 {
        let v = Arc::clone(&v);
        handles.push(thread::spawn(move || {
            let mut v = v.lock().unwrap();
            v.push(10 * i);
            println!("v: {v:?}");
        }));
    }

    handles.into_iter().for_each(|h| h.join().unwrap());
}

Важные моменты:

  • v обёрнут и в Arc, и в Mutex, потому что их задачи ортогональны.
    • Обёртывание Mutex в Arc — распространённый паттерн для совместного использования изменяемого состояния между потоками.
  • v: Arc<_> нужно клонировать, чтобы создать новую ссылку для каждого нового порождённого потока. Обратите внимание, что в сигнатуру замыкания добавлено move.
  • Введены блоки, чтобы максимально сузить область видимости LockGuard.

Упражнения

Этот сегмент займёт около 1 часа 10 минут. Он включает:

СлайдДлительность
Обедающие философы20 минут
Многопоточная проверка ссылок20 минут
Решения30 минут

Обедающие философы

Задача об обедающих философах — классическая задача конкурентности:

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

Для этого упражнения понадобится локальная установка Cargo. Скопируйте приведённый ниже код в файл src/main.rs, заполните пропуски и проверьте, что cargo run не приводит к взаимной блокировке (deadlock):

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::sync::{Arc, Mutex, mpsc};
use std::thread;
use std::time::Duration;

struct Chopstick;

struct Philosopher {
    name: String,
    // left_chopstick: ...
    // right_chopstick: ...
    // thoughts: ...
}

impl Philosopher {
    fn think(&self) {
        self.thoughts
            .send(format!("Eureka! {} has a new idea!", self.name))
            .unwrap();
    }

    fn eat(&self) {
        // Берём палочки...
        println!("{} is eating...", self.name);
        thread::sleep(Duration::from_millis(10));
    }
}

static PHILOSOPHERS: &[&str] =
    &["Socrates", "Hypatia", "Plato", "Aristotle", "Pythagoras"];

fn main() {
    // Создаём палочки

    // Создаём философов

    // Заставляем каждого думать и есть 100 раз

    // Выводим их мысли
}

Можно использовать следующий Cargo.toml:

[package]
name = "dining-philosophers"
version = "0.1.0"
edition = "2024"
This slide should take about 20 minutes.
  • Поощряйте студентов сначала сосредоточиться на реализации решения, которое «в основном» работает.
  • Взаимная блокировка в простейшем решении — общая проблема конкурентности, и она показывает, что Rust не предотвращает автоматически такие ошибки.

Многопоточная проверка ссылок

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

Для этого понадобится HTTP-клиент, например reqwest. Также нужен способ находить ссылки; можно использовать scraper. Наконец, нам понадобится способ обрабатывать ошибки; мы будем использовать thiserror.

Создайте новый проект Cargo и добавьте reqwest как зависимость командой:

cargo new link-checker
cd link-checker
cargo add --features blocking reqwest
cargo add scraper
cargo add thiserror

Если cargo add завершается с ошибкой error: no such subcommand, отредактируйте файл Cargo.toml вручную. Добавьте зависимости, перечисленные ниже.

Вызовы cargo add обновят файл Cargo.toml так:

[package]
name = "link-checker"
version = "0.1.0"
edition = "2024"
publish = false

[dependencies]
reqwest = { version = "0.13.1", features = ["blocking"] }
scraper = "0.25.0"
thiserror = "2.0.18"

Теперь можно загрузить стартовую страницу. Попробуйте на небольшом сайте, например https://www.google.org/.

Ваш файл src/main.rs должен выглядеть примерно так:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use reqwest::Url;
use reqwest::blocking::Client;
use scraper::{Html, Selector};
use thiserror::Error;

#[derive(Error, Debug)]
enum Error {
    #[error("request error: {0}")]
    ReqwestError(#[from] reqwest::Error),
    #[error("bad http response: {0}")]
    BadResponse(String),
}

#[derive(Debug)]
struct CrawlCommand {
    url: Url,
    extract_links: bool,
}

fn visit_page(client: &Client, command: &CrawlCommand) -> Result<Vec<Url>, Error> {
    println!("Checking {:#}", command.url);
    let response = client.get(command.url.clone()).send()?;
    if !response.status().is_success() {
        return Err(Error::BadResponse(response.status().to_string()));
    }

    let mut link_urls = Vec::new();
    if !command.extract_links {
        return Ok(link_urls);
    }

    let base_url = response.url().clone();
    let body_text = response.text()?;
    let document = Html::parse_document(&body_text);

    let selector = Selector::parse("a").unwrap();
    let href_values = document
        .select(&selector)
        .filter_map(|element| element.value().attr("href"));
    for href in href_values {
        match base_url.join(href) {
            Ok(link_url) => {
                link_urls.push(link_url);
            }
            Err(err) => {
                println!("On {base_url:#}: ignored unparsable {href:?}: {err}");
            }
        }
    }
    Ok(link_urls)
}

fn main() {
    let client = Client::new();
    let start_url = Url::parse("https://www.google.org").unwrap();
    let crawl_command = CrawlCommand{ url: start_url, extract_links: true };
    match visit_page(&client, &crawl_command) {
        Ok(links) => println!("Links: {links:#?}"),
        Err(err) => println!("Could not extract links: {err:#}"),
    }
}

Запустите код в src/main.rs командой

cargo run

Задачи

  • Используйте потоки, чтобы проверять ссылки параллельно: отправляйте URL для проверки в канал и пусть несколько потоков проверяют URL параллельно.
  • Расширьте это, чтобы рекурсивно извлекать ссылки со всех страниц домена www.google.org. Установите верхний предел около 100 страниц, чтобы сайт не заблокировал вас.
This slide should take about 20 minutes.
  • Это сложное упражнение, призванное дать студентам возможность поработать над более крупным проектом, чем другие. Критерий успеха этого упражнения — упереться в какую-то «настоящую» проблему и разобраться с ней при поддержке других студентов или преподавателя.

Решения

Обедающие философы

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::sync::{Arc, Mutex, mpsc};
use std::thread;
use std::time::Duration;

struct Chopstick;

struct Philosopher {
    name: String,
    left_chopstick: Arc<Mutex<Chopstick>>,
    right_chopstick: Arc<Mutex<Chopstick>>,
    thoughts: mpsc::SyncSender<String>,
}

impl Philosopher {
    fn think(&self) {
        self.thoughts
            .send(format!("Eureka! {} has a new idea!", self.name))
            .unwrap();
    }

    fn eat(&self) {
        println!("{} is trying to eat", self.name);
        let _left = self.left_chopstick.lock().unwrap();
        let _right = self.right_chopstick.lock().unwrap();

        println!("{} is eating...", self.name);
        thread::sleep(Duration::from_millis(10));
    }
}

static PHILOSOPHERS: &[&str] =
    &["Socrates", "Hypatia", "Plato", "Aristotle", "Pythagoras"];

fn main() {
    let (tx, rx) = mpsc::sync_channel(10);

    let chopsticks = PHILOSOPHERS
        .iter()
        .map(|_| Arc::new(Mutex::new(Chopstick)))
        .collect::<Vec<_>>();

    for i in 0..chopsticks.len() {
        let tx = tx.clone();
        let mut left_chopstick = Arc::clone(&chopsticks[i]);
        let mut right_chopstick =
            Arc::clone(&chopsticks[(i + 1) % chopsticks.len()]);

        // Чтобы избежать взаимной блокировки, нужно нарушить симметрию
        // где-то. Здесь палочки меняются местами без деинициализации
        // ни одной из них.
        if i == chopsticks.len() - 1 {
            std::mem::swap(&mut left_chopstick, &mut right_chopstick);
        }

        let philosopher = Philosopher {
            name: PHILOSOPHERS[i].to_string(),
            thoughts: tx,
            left_chopstick,
            right_chopstick,
        };

        thread::spawn(move || {
            for _ in 0..100 {
                philosopher.eat();
                philosopher.think();
            }
        });
    }

    drop(tx);
    for thought in rx {
        println!("{thought}");
    }
}

Проверщик ссылок

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::sync::{Arc, Mutex, mpsc};
use std::thread;

use reqwest::Url;
use reqwest::blocking::Client;
use scraper::{Html, Selector};
use thiserror::Error;

#[derive(Error, Debug)]
enum Error {
    #[error("request error: {0}")]
    ReqwestError(#[from] reqwest::Error),
    #[error("bad http response: {0}")]
    BadResponse(String),
}

#[derive(Debug)]
struct CrawlCommand {
    url: Url,
    extract_links: bool,
}

fn visit_page(client: &Client, command: &CrawlCommand) -> Result<Vec<Url>, Error> {
    println!("Checking {:#}", command.url);
    let response = client.get(command.url.clone()).send()?;
    if !response.status().is_success() {
        return Err(Error::BadResponse(response.status().to_string()));
    }

    let mut link_urls = Vec::new();
    if !command.extract_links {
        return Ok(link_urls);
    }

    let base_url = response.url().clone();
    let body_text = response.text()?;
    let document = Html::parse_document(&body_text);

    let selector = Selector::parse("a").unwrap();
    let href_values = document
        .select(&selector)
        .filter_map(|element| element.value().attr("href"));
    for href in href_values {
        match base_url.join(href) {
            Ok(link_url) => {
                link_urls.push(link_url);
            }
            Err(err) => {
                println!("On {base_url:#}: ignored unparsable {href:?}: {err}");
            }
        }
    }
    Ok(link_urls)
}

struct CrawlState {
    domain: String,
    visited_pages: std::collections::HashSet<String>,
}

impl CrawlState {
    fn new(start_url: &Url) -> CrawlState {
        let mut visited_pages = std::collections::HashSet::new();
        visited_pages.insert(start_url.as_str().to_string());
        CrawlState { domain: start_url.domain().unwrap().to_string(), visited_pages }
    }

    /// Определяет, нужно ли извлекать ссылки на заданной странице.
    fn should_extract_links(&self, url: &Url) -> bool {
        url.domain().is_some_and(|d| d == self.domain)
    }

    /// Помечает заданную страницу как посещённую; возвращает false, если она уже была посещена.
    fn mark_visited(&mut self, url: &Url) -> bool {
        self.visited_pages.insert(url.as_str().to_string())
    }
}

type CrawlResult = Result<Vec<Url>, (Url, Error)>;

fn spawn_crawler_threads(
    command_receiver: mpsc::Receiver<CrawlCommand>,
    result_sender: mpsc::Sender<CrawlResult>,
    thread_count: u32,
) {
    // Чтобы мультиплексировать неклонируемый Receiver, оберните его в Arc<Mutex<_>>.
    let command_receiver = Arc::new(Mutex::new(command_receiver));

    for _ in 0..thread_count {
        let result_sender = result_sender.clone();
        let command_receiver = Arc::clone(&command_receiver);
        thread::spawn(move || {
            let client = Client::new();
            loop {
                let command_result = {
                    let receiver_guard = command_receiver.lock().unwrap();
                    receiver_guard.recv()
                };
                let Ok(crawl_command) = command_result else {
                    // Отправитель освобождён. Новых команд больше не поступает.
                    break;
                };
                let crawl_result = match visit_page(&client, &crawl_command) {
                    Ok(link_urls) => Ok(link_urls),
                    Err(error) => Err((crawl_command.url, error)),
                };
                result_sender.send(crawl_result).unwrap();
            }
        });
    }
}

fn control_crawl(
    start_url: Url,
    command_sender: mpsc::Sender<CrawlCommand>,
    result_receiver: mpsc::Receiver<CrawlResult>,
) -> Vec<Url> {
    let mut crawl_state = CrawlState::new(&start_url);
    let start_command = CrawlCommand { url: start_url, extract_links: true };
    command_sender.send(start_command).unwrap();
    let mut pending_urls = 1;

    let mut bad_urls = Vec::new();
    while pending_urls > 0 {
        let crawl_result = result_receiver.recv().unwrap();
        pending_urls -= 1;

        match crawl_result {
            Ok(link_urls) => {
                for url in link_urls {
                    if crawl_state.mark_visited(&url) {
                        let extract_links = crawl_state.should_extract_links(&url);
                        let crawl_command = CrawlCommand { url, extract_links };
                        command_sender.send(crawl_command).unwrap();
                        pending_urls += 1;
                    }
                }
            }
            Err((url, error)) => {
                bad_urls.push(url);
                println!("Got crawling error: {:#}", error);
            }
        }
    }
    bad_urls
}

fn check_links(start_url: Url) -> Vec<Url> {
    let (result_sender, result_receiver) = mpsc::channel::<CrawlResult>();
    let (command_sender, command_receiver) = mpsc::channel::<CrawlCommand>();
    spawn_crawler_threads(command_receiver, result_sender, 16);
    control_crawl(start_url, command_sender, result_receiver)
}

fn main() {
    let start_url = reqwest::Url::parse("https://www.google.org").unwrap();
    let bad_urls = check_links(start_url);
    println!("Bad URLs: {:#?}", bad_urls);
}

Добро пожаловать

«Async» — модель конкурентности, в которой несколько задач выполняются одновременно: каждая задача выполняется до тех пор, пока не заблокируется, затем управление переходит к другой задаче, которая готова продолжить работу. Модель позволяет запускать большое количество задач на ограниченном числе потоков. Это происходит потому, что накладные расходы на задачу обычно очень малы, а операционные системы предоставляют примитивы для эффективного определения того, какой ввод-вывод может продолжаться.

Асинхронная работа в Rust основана на «фьючерсах» (futures), которые представляют работу, которая может быть завершена в будущем. Фьючерсы «опрашиваются» (polled), пока не сообщат, что завершены.

Фьючерсы опрашивает асинхронный рантайм, и доступно несколько разных рантаймов.

Сравнения

  • В Python похожая модель в asyncio. Однако его тип Future основан на колбэках и не опрашивается. Асинхронным программам на Python нужен «цикл» (loop), похожий на рантайм в Rust.

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

Расписание

Включая 10-минутные перерывы, это занятие займёт около 3 часов 30 минут. Оно включает:

СегментДлительность
Основы асинхронности40 минут
Каналы и поток управления20 минут
Подводные камни55 минут
Упражнения1 час 10 минут

Основы асинхронности

Этот сегмент займёт около 40 минут. Он включает:

СлайдДлительность
async/await10 минут
Futures4 минуты
Конечный автомат10 минут
Рантаймы10 минут
Задачи10 минут

async/await

На высоком уровне асинхронный код на Rust очень похож на «обычный» последовательный код:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use futures::executor::block_on;

async fn count_to(count: i32) {
    for i in 0..count {
        println!("Count is: {i}!");
    }
}

async fn async_main(count: i32) {
    count_to(count).await;
}

fn main() {
    block_on(async_main(10));
}
This slide should take about 6 minutes.

Ключевые моменты:

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

  • Ключевое слово async — синтаксический сахар. Компилятор заменяет возвращаемый тип на фьючерс.

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

  • Чтобы запустить асинхронный код, нужен исполнитель (executor). block_on блокирует текущий поток, пока переданный фьючерс не выполнится до конца.

  • .await асинхронно ждёт завершения другой операции. В отличие от block_on, .await не блокирует текущий поток.

  • .await можно использовать только внутри функции async (или блока async; о них расскажем позже).

Futures

Future — трейт, который реализуют объекты, представляющие операцию, которая ещё может не быть завершена. Фьючерс можно опрашивать, и poll возвращает Poll.

#![allow(unused)]
fn main() {
// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::pin::Pin;
use std::task::Context;

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

pub enum Poll<T> {
    Ready(T),
    Pending,
}
}

Асинхронная функция возвращает impl Future. Также возможно (но необычно) реализовать Future для собственных типов. Например, JoinHandle, возвращаемый из tokio::spawn, реализует Future, чтобы можно было дождаться его завершения.

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

This slide should take about 4 minutes.
  • Типы Future и Poll реализованы именно так, как показано; нажмите на ссылки, чтобы увидеть реализации в документации.

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

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

Конечный автомат

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

#![allow(unused)]
fn main() {
// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Сложить два броска D10 и модификатор.
async fn two_d10(modifier: u32) -> u32 {
    let first_roll = roll_d10().await;
    let second_roll = roll_d10().await;
    first_roll + second_roll + modifier
}
}

преобразуется примерно в следующее:

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

/// Сложить два броска D10 и модификатор.
fn two_d10(modifier: u32) -> TwoD10 {
    TwoD10::Init { modifier }
}

enum TwoD10 {
    // Функция ещё не начала выполняться.
    Init { modifier: u32 },
    // Ожидаем завершения первого `.await`.
    FirstRoll { modifier: u32, fut: RollD10Future },
    // Ожидаем завершения второго `.await`.
    SecondRoll { modifier: u32, first_roll: u32, fut: RollD10Future },
}

impl Future for TwoD10 {
    type Output = u32;
    fn poll(mut self: Pin<&mut Self>, ctx: &mut Context) -> Poll<Self::Output> {
        loop {
            match *self {
                TwoD10::Init { modifier } => {
                    // Создаём фьючерс для первого броска кубика.
                    let fut = roll_d10();
                    *self = TwoD10::FirstRoll { modifier, fut };
                }
                TwoD10::FirstRoll { modifier, ref mut fut } => {
                    // Опрашиваем подфьючерс для первого броска.
                    if let Poll::Ready(first_roll) = fut.poll(ctx) {
                        // Создаём фьючерс для второго броска.
                        let fut = roll_d10();
                        *self = TwoD10::SecondRoll { modifier, first_roll, fut };
                    } else {
                        return Poll::Pending;
                    }
                }
                TwoD10::SecondRoll { modifier, first_roll, ref mut fut } => {
                    // Опрашиваем подфьючерс для второго броска.
                    if let Poll::Ready(second_roll) = fut.poll(ctx) {
                        return Poll::Ready(first_roll + second_roll + modifier);
                    } else {
                        return Poll::Pending;
                    }
                }
            }
        }
    }
}
This slide should take about 10 minutes.

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

  • Вызов асинхронной функции ничего не делает, кроме построения и возврата фьючерса.
  • Все локальные переменные хранятся во фьючерсе функции; перечисление (enum) указывает, где выполнение сейчас приостановлено.
  • Каждый .await в асинхронной функции преобразуется в новое состояние, содержащее все живые переменные и ожидаемый фьючерс. Затем loop обрабатывает обновлённое состояние, опрашивая фьючерс, пока он не вернёт Poll::Ready.
  • Выполнение продолжается без задержек, пока не случится Poll::Pending. В этом простом примере каждый фьючерс готов сразу.
  • В main содержится наивный исполнитель, который просто крутится в цикле, пока фьючерс не будет готов. Настоящие исполнители мы обсудим чуть позже.

Для дальнейшего изучения

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

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

#![allow(unused)]
fn main() {
// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

enum LinkedList<T> {
    Node { value: T, next: LinkedList<T> },
    Nil,
}
}

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

async fn count_to(n: u32) {
    if n > 0 {
        Box::pin(count_to(n - 1)).await;
        println!("{n}");
    }
}

Рантаймы

Рантайм обеспечивает выполнение операций асинхронно (реактор) и отвечает за выполнение фьючерсов (исполнитель). В Rust нет «встроенного» рантайма, но доступно несколько вариантов:

  • Tokio: производительный, с хорошо развитой экосистемой функциональности, например Hyper для HTTP или Tonic для gRPC.
  • smol: простой и лёгкий.

Некоторые крупные приложения имеют собственные рантаймы. Например, Fuchsia уже имеет такой.

This slide and its sub-slides should take about 10 minutes.
  • Обратите внимание, что из перечисленных рантаймов в Rust Playground поддерживается только Tokio. Playground также не разрешает никакого ввода-вывода, поэтому большинство интересных асинхронных примеров там не запустятся.

  • Фьючерсы «инертны»: они ничего не делают (даже не начинают операцию ввода-вывода), пока исполнитель их не опрашивает. Это отличается, например, от JS Promise, который выполнится до конца, даже если его никто не использует.

Tokio

Tokio предоставляет:

  • Многопоточный рантайм для выполнения асинхронного кода.
  • Асинхронную версию стандартной библиотеки.
  • Большую экосистему библиотек.
// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use tokio::time;

async fn count_to(count: i32) {
    for i in 0..count {
        println!("Count in task: {i}!");
        time::sleep(time::Duration::from_millis(5)).await;
    }
}

#[tokio::main]
async fn main() {
    tokio::spawn(count_to(10));

    for i in 0..5 {
        println!("Main task: {i}");
        time::sleep(time::Duration::from_millis(5)).await;
    }
}
  • С помощью макроса tokio::main мы теперь можем сделать main асинхронной.

  • Функция spawn создаёт новую конкурентную «задачу».

  • Примечание: spawn принимает Future, поэтому не нужно вызывать .await у count_to.

Дальнейшее изучение:

  • Почему count_to не доходит до 10? Это пример отмены асинхронной операции. tokio::spawn возвращает дескриптор, который можно дождаться через .await, чтобы подождать его завершения.

  • Попробуйте count_to(10).await вместо запуска в отдельной задаче.

  • Попробуйте дождаться задачи, возвращённой из tokio::spawn.

Задачи

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

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

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;

#[tokio::main]
async fn main() -> io::Result<()> {
    let listener = TcpListener::bind("127.0.0.1:0").await?;
    println!("listening on port {}", listener.local_addr()?.port());

    loop {
        let (mut socket, addr) = listener.accept().await?;

        println!("connection from {addr:?}");

        tokio::spawn(async move {
            socket.write_all(b"Who are you?\n").await.expect("socket error");

            let mut buf = vec![0; 1024];
            let name_size = socket.read(&mut buf).await.expect("socket error");
            let name = std::str::from_utf8(&buf[..name_size]).unwrap().trim();
            let reply = format!("Thanks for dialing in, {name}!\n");
            socket.write_all(reply.as_bytes()).await.expect("socket error");
        });
    }
}
This slide should take about 6 minutes.

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

Попробуйте подключиться к нему с помощью инструмента для TCP-соединений, например nc или telnet.

  • Попросите студентов представить, каким будет состояние примера сервера с несколькими подключёнными клиентами. Какие задачи существуют? Каковы их фьючерсы?

  • Это первый раз, когда мы видим блок async. Он похож на замыкание, но не принимает аргументов. Его возвращаемое значение — фьючерс, как и у async fn.

  • Перепишите блок async в функцию и улучшите обработку ошибок с помощью ?.

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

Этот сегмент займёт около 20 минут. Он включает:

СлайдДлительность
Асинхронные каналы10 минут
Join4 минуты
Select5 минут

Асинхронные каналы

Несколько крейтов поддерживают асинхронные каналы. Например, tokio:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use tokio::sync::mpsc;

async fn ping_handler(mut input: mpsc::Receiver<()>) {
    let mut count: usize = 0;

    while let Some(_) = input.recv().await {
        count += 1;
        println!("Received {count} pings so far.");
    }

    println!("ping_handler complete");
}

#[tokio::main]
async fn main() {
    let (sender, receiver) = mpsc::channel(32);
    let ping_handler_task = tokio::spawn(ping_handler(receiver));
    for i in 0..10 {
        sender.send(()).await.expect("Failed to send ping.");
        println!("Sent {} pings so far.", i + 1);
    }

    drop(sender);
    ping_handler_task.await.expect("Something went wrong in ping handler task.");
}
This slide should take about 8 minutes.
  • Измените размер канала на 3 и посмотрите, как это повлияет на выполнение.

  • В целом интерфейс похож на синхронные каналы, которые мы видели на утреннем занятии.

  • Попробуйте убрать вызов std::mem::drop. Что произойдёт? Почему?

  • Крейт Flume содержит каналы, которые реализуют и синхронные, и асинхронные send и recv. Это может быть удобно в сложных приложениях с задачами и вводом-выводом, и тяжёлой процессорной обработкой.

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

Join

Операция join ждёт, пока все фьючерсы из набора не будут готовы, и возвращает коллекцию их результатов. Это похоже на Promise.all в JavaScript или asyncio.gather в Python.

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use anyhow::Result;
use futures::future;
use reqwest;
use std::collections::HashMap;

async fn size_of_page(url: &str) -> Result<usize> {
    let resp = reqwest::get(url).await?;
    Ok(resp.text().await?.len())
}

#[tokio::main]
async fn main() {
    let urls: [&str; 4] = [
        "https://google.com",
        "https://httpbin.org/ip",
        "https://play.rust-lang.org/",
        "BAD_URL",
    ];
    let futures_iter = urls.into_iter().map(size_of_page);
    let results = future::join_all(futures_iter).await;
    let page_sizes_dict: HashMap<&str, Result<usize>> =
        urls.into_iter().zip(results.into_iter()).collect();
    println!("{page_sizes_dict:?}");
}
This slide should take about 4 minutes.

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

  • Для нескольких фьючерсов разных типов можно использовать std::future::join!, но нужно знать количество фьючерсов во время компиляции. Сейчас он находится в крейте futures, скоро будет стабилизирован в std::future.

  • Риск join в том, что один из фьючерсов может никогда не завершиться, и тогда программа зависнет.

  • Также можно комбинировать join_all с join!, например чтобы дождаться всех запросов к HTTP-сервису и запроса к базе данных. Попробуйте добавить tokio::time::sleep в фьючерс с использованием futures::join!. Это не таймаут (для него нужен select!, о котором пойдёт речь в следующей главе), но это демонстрирует join!.

Select

Операция select ждёт, пока любой из набора фьючерсов не будет готов, и реагирует на результат этого фьючерса. В JavaScript это похоже на Promise.race. В Python аналог — asyncio.wait(task_set, return_when=asyncio.FIRST_COMPLETED).

Подобно оператору match, тело select! содержит несколько ветвей, каждая в виде pattern = future => statement. Когда future готов, его возвращаемое значение деструктурируется по pattern. Затем statement выполняется с полученными переменными. Результат statement становится результатом макроса select!.

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use tokio::sync::mpsc;
use tokio::time::{Duration, sleep};

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel(32);
    let listener = tokio::spawn(async move {
        tokio::select! {
            Some(msg) = rx.recv() => println!("got: {msg}"),
            _ = sleep(Duration::from_millis(50)) => println!("timeout"),
        };
    });
    sleep(Duration::from_millis(10)).await;
    tx.send(String::from("Hello!")).await.expect("Failed to send greeting");

    listener.await.expect("Listener failed");
}
This slide should take about 5 minutes.
  • Асинхронный блок listener здесь — распространённая форма: ждать какого-нибудь асинхронного события или таймаута. Измените sleep, чтобы он спал дольше, и посмотрите, как всё сломается. Почему в этой ситуации тоже не удаётся send?

  • select! также часто используется в цикле в архитектурах «акторов», где задача реагирует на события в цикле. У этого есть свои подводные камни, которые обсудим в следующем сегменте.

Подводные камни

Async / await даёт удобную и эффективную абстракцию для конкурентного асинхронного программирования. Однако модель async/await в Rust также имеет свои подводные камни и «footguns» (мины, на которые легко наступить). В этой главе мы рассмотрим некоторые из них.

Этот сегмент займёт около 55 минут. Он включает:

СлайдДлительность
Блокировка исполнителя10 минут
Pin20 минут
Асинхронные трейты5 минут
Отмена20 минут

Блокировка исполнителя

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

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use futures::future::join_all;
use std::time::Instant;

async fn sleep_ms(start: &Instant, id: u64, duration_ms: u64) {
    std::thread::sleep(std::time::Duration::from_millis(duration_ms));
    println!(
        "future {id} slept for {duration_ms}ms, finished after {}ms",
        start.elapsed().as_millis()
    );
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let start = Instant::now();
    let sleep_futures = (1..=10).map(|t| sleep_ms(&start, t, t * 10));
    join_all(sleep_futures).await;
}
This slide should take about 10 minutes.
  • Запустите код и убедитесь, что паузы выполняются последовательно, а не одновременно.

  • Вариант "current_thread" помещает все задачи в один поток. Это делает эффект более очевидным, но ошибка сохраняется и в многопоточном варианте.

  • Замените std::thread::sleep на tokio::time::sleep и дождитесь его результата через .await.

  • Другое решение — tokio::task::spawn_blocking, который создаёт настоящий поток и превращает его дескриптор во фьючерс, не блокируя исполнитель.

  • Не стоит считать задачи потоками ОС. Они не соответствуют друг другу 1 к 1, и исполнители позволяют нескольким задачам выполняться в одном потоке ОС. Это особенно проблемно при взаимодействии с другими библиотеками через FFI, где библиотека может зависеть от локального хранилища потока или сопоставлять себя с конкретными потоками ОС (например, CUDA). В таких ситуациях предпочитайте tokio::task::spawn_blocking.

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

Pin

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

Чтобы предотвратить перемещение типа фьючерса в памяти, его можно опрашивать только через закреплённый (pinned) указатель. Pin — обёртка вокруг ссылки, которая запрещает все операции, перемещающие объект, на который она указывает, в другое место в памяти.

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use tokio::sync::{mpsc, oneshot};
use tokio::task::spawn;
use tokio::time::{Duration, sleep};

// Элемент работы. В данном случае просто спим заданное время и отвечаем
// сообщением в канал `respond_on`.
#[derive(Debug)]
struct Work {
    input: u32,
    respond_on: oneshot::Sender<u32>,
}

// Рабочий, который слушает очередь заданий и выполняет их.
async fn worker(mut work_queue: mpsc::Receiver<Work>) {
    let mut iterations = 0;
    loop {
        tokio::select! {
            Some(work) = work_queue.recv() => {
                sleep(Duration::from_millis(10)).await; // Делаем вид, что работаем.
                work.respond_on
                    .send(work.input * 1000)
                    .expect("failed to send response");
                iterations += 1;
            }
            // TODO: сообщать количество итераций каждые 100 мс
        }
    }
}

// Запрашивающий, который запрашивает работу и ждёт её завершения.
async fn do_work(work_queue: &mpsc::Sender<Work>, input: u32) -> u32 {
    let (tx, rx) = oneshot::channel();
    work_queue
        .send(Work { input, respond_on: tx })
        .await
        .expect("failed to send on work queue");
    rx.await.expect("failed waiting for response")
}

#[tokio::main]
async fn main() {
    let (tx, rx) = mpsc::channel(10);
    spawn(worker(rx));
    for i in 0..100 {
        let resp = do_work(&tx, i).await;
        println!("work result for iteration {i}: {resp}");
    }
}
This slide should take about 20 minutes.
  • Вы можете узнать в этом пример паттерна «актор». Акторы обычно вызывают select! в цикле.

  • Это сводка нескольких предыдущих уроков, так что не торопитесь.

    • Наивно добавьте _ = sleep(Duration::from_millis(100)) => { println!(..) } в select!. Это никогда не выполнится. Почему?

    • Вместо этого добавьте timeout_fut, содержащий этот фьючерс, вне loop:

      #![allow(unused)]
      fn main() {
      // Copyright 2024 Google LLC
      // SPDX-License-Identifier: Apache-2.0
      
      let timeout_fut = sleep(Duration::from_millis(100));
      loop {
          select! {
              ..,
              _ = timeout_fut => { println!(..); },
          }
      }
      }
    • Это всё ещё не работает. Следуйте сообщениям компилятора: добавьте &mut к timeout_fut в select!, чтобы обойти перемещение, а затем используйте Box::pin:

      #![allow(unused)]
      fn main() {
      // Copyright 2024 Google LLC
      // SPDX-License-Identifier: Apache-2.0
      
      let mut timeout_fut = Box::pin(sleep(Duration::from_millis(100)));
      loop {
          select! {
              ..,
              _ = &mut timeout_fut => { println!(..); },
          }
      }
      }
    • Теперь это компилируется, но после истечения таймаута он Poll::Ready на каждой итерации (fused-фьючерс здесь помог бы). Обновите, чтобы сбрасывать timeout_fut каждый раз, когда он истекает:

      #![allow(unused)]
      fn main() {
      // Copyright 2024 Google LLC
      // SPDX-License-Identifier: Apache-2.0
      
      let mut timeout_fut = Box::pin(sleep(Duration::from_millis(100)));
      loop {
          select! {
              _ = &mut timeout_fut => {
                  println!(..);
                  timeout_fut = Box::pin(sleep(Duration::from_millis(100)));
              },
          }
      }
      }
  • Box выделяет память в куче. В некоторых случаях также подходит std::pin::pin! (стабилизирован совсем недавно; в старом коде часто используют tokio::pin!), но его сложно использовать для фьючерса, которому присваивают новое значение.

  • Другая альтернатива — не использовать pin вообще, а запустить ещё одну задачу, которая будет отправлять сообщение в канал oneshot каждые 100 мс.

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

  • Pin — обёртка вокруг ссылки. Объект нельзя переместить с его места с помощью закреплённого указателя. Однако его по-прежнему можно переместить через незакреплённый указатель.

  • Метод poll трейта Future использует Pin<&mut Self> вместо &mut Self, чтобы ссылаться на экземпляр. Поэтому его можно вызвать только через закреплённый указатель.

Асинхронные трейты

Асинхронные методы в трейтах были стабилизированы в релизе 1.75. Для этого потребовалась поддержка impl Trait в возвращаемой позиции внутри трейтов, так как десахаризация async fn включает -> impl Future<Output = ...>.

Однако даже при нативной поддержке есть особые подводные камни, связанные с async fn:

  • impl Trait в возвращаемой позиции захватывает все времена жизни из области видимости (поэтому некоторые шаблоны заимствования нельзя выразить).

  • Асинхронные трейты нельзя использовать с объектами трейтов (нет поддержки dyn Trait).

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

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use async_trait::async_trait;
use std::time::Instant;
use tokio::time::{Duration, sleep};

#[async_trait]
trait Sleeper {
    async fn sleep(&self);
}

struct FixedSleeper {
    sleep_ms: u64,
}

#[async_trait]
impl Sleeper for FixedSleeper {
    async fn sleep(&self) {
        sleep(Duration::from_millis(self.sleep_ms)).await;
    }
}

async fn run_all_sleepers_multiple_times(
    sleepers: Vec<Box<dyn Sleeper>>,
    n_times: usize,
) {
    for _ in 0..n_times {
        println!("Running all sleepers...");
        for sleeper in &sleepers {
            let start = Instant::now();
            sleeper.sleep().await;
            println!("Slept for {} ms", start.elapsed().as_millis());
        }
    }
}

#[tokio::main]
async fn main() {
    let sleepers: Vec<Box<dyn Sleeper>> = vec![
        Box::new(FixedSleeper { sleep_ms: 50 }),
        Box::new(FixedSleeper { sleep_ms: 100 }),
    ];
    run_all_sleepers_multiple_times(sleepers, 5).await;
}
This slide should take about 5 minutes.
  • async_trait легко использовать, но обратите внимание, что для этого он использует выделения памяти в куче. Эти выделения создают накладные расходы на производительность.

  • Сложности поддержки языком async trait слишком глубоки, чтобы подробно описывать их на этом занятии. Если хотите разобраться глубже, см. эту запись в блоге Ники Мацакиса. Также см. эти ключевые слова:

  • Попробуйте создать новую структуру «спящего», которая будет спать случайное время, и добавить её в Vec.

Отмена

Удаление фьючерса означает, что его больше никогда нельзя будет опросить. Это называется отменой (cancellation), и она может произойти в любой точке await. Нужна осторожность, чтобы система работала корректно даже тогда, когда фьючерсы отменяются. Например, она не должна входить во взаимную блокировку или терять данные.

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::io;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream};

struct LinesReader {
    stream: DuplexStream,
}

impl LinesReader {
    fn new(stream: DuplexStream) -> Self {
        Self { stream }
    }

    async fn next(&mut self) -> io::Result<Option<String>> {
        let mut bytes = Vec::new();
        let mut buf = [0];
        while self.stream.read(&mut buf[..]).await? != 0 {
            bytes.push(buf[0]);
            if buf[0] == b'\n' {
                break;
            }
        }
        if bytes.is_empty() {
            return Ok(None);
        }
        let s = String::from_utf8(bytes)
            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "not UTF-8"))?;
        Ok(Some(s))
    }
}

async fn slow_copy(source: String, mut dest: DuplexStream) -> io::Result<()> {
    for b in source.bytes() {
        dest.write_u8(b).await?;
        tokio::time::sleep(Duration::from_millis(10)).await
    }
    Ok(())
}

#[tokio::main]
async fn main() -> io::Result<()> {
    let (client, server) = tokio::io::duplex(5);
    let handle = tokio::spawn(slow_copy("hi\nthere\n".to_owned(), client));

    let mut lines = LinesReader::new(server);
    let mut interval = tokio::time::interval(Duration::from_millis(60));
    loop {
        tokio::select! {
            _ = interval.tick() => println!("tick!"),
            line = lines.next() => if let Some(l) = line? {
                print!("{}", l)
            } else {
                break
            },
        }
    }
    handle.await.unwrap()?;
    Ok(())
}
This slide should take about 18 minutes.
  • Компилятор не помогает с безопасностью при отмене. Нужно читать документацию API и учитывать, какое состояние хранит ваша async fn.

  • В отличие от panic и ?, отмена — часть обычного потока управления (а не обработки ошибок).

  • В примере теряются части строки.

    • Когда ветка tick() завершается первой, next() и его buf уничтожаются.

    • LinesReader можно сделать безопасным к отмене, включив buf в структуру:

      #![allow(unused)]
      fn main() {
      // Copyright 2024 Google LLC
      // SPDX-License-Identifier: Apache-2.0
      
      struct LinesReader {
          stream: DuplexStream,
          bytes: Vec<u8>,
          buf: [u8; 1],
      }
      
      impl LinesReader {
          fn new(stream: DuplexStream) -> Self {
              Self { stream, bytes: Vec::new(), buf: [0] }
          }
          async fn next(&mut self) -> io::Result<Option<String>> {
              // добавьте префикс self. к buf и bytes.
              // ...
              let raw = std::mem::take(&mut self.bytes);
              let s = String::from_utf8(raw)
                  .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "not UTF-8"))?;
              // ...
          }
      }
      }
  • Interval::tick безопасен к отмене, потому что отслеживает, было ли «доставлено» тиканье.

  • AsyncReadExt::read безопасен к отмене, потому что либо возвращает данные, либо не читает их.

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

Упражнения

Этот сегмент займёт около 1 часа 10 минут. Он включает:

СлайдДлительность
Обедающие философы20 минут
Приложение чата с широковещательной рассылкой30 минут
Решения20 минут

Обедающие философы — асинхронная версия

Описание задачи см. в обедающих философах.

Как и раньше, для этого упражнения понадобится локальная установка Cargo. Скопируйте приведённый ниже код в файл src/main.rs, заполните пропуски и проверьте, что cargo run не приводит к взаимной блокировке:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
use tokio::time;

struct Chopstick;

struct Philosopher {
    name: String,
    // left_chopstick: ...
    // right_chopstick: ...
    // thoughts: ...
}

impl Philosopher {
    async fn think(&self) {
        self.thoughts
            .send(format!("Eureka! {} has a new idea!", &self.name))
            .await
            .unwrap();
    }

    async fn eat(&self) {
        // Продолжаем пытаться, пока не получим обе палочки
        println!("{} is eating...", &self.name);
        time::sleep(time::Duration::from_millis(5)).await;
    }
}

// Планировщик tokio не приводит к взаимной блокировке с 5 философами, поэтому возьмём 2.
static PHILOSOPHERS: &[&str] = &["Socrates", "Hypatia"];

#[tokio::main]
async fn main() {
    // Создаём палочки

    // Создаём философов

    // Пусть они думают и едят

    // Выводим их мысли
}

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

[package]
name = "dining-philosophers-async-dine"
version = "0.1.0"
edition = "2024"

[dependencies]
tokio = { version = "1.26.0", features = ["sync", "time", "macros", "rt-multi-thread"] }

Обратите также внимание, что на этот раз нужно использовать Mutex и модуль mpsc из крейта tokio.

This slide should take about 20 minutes.
  • Можете ли вы сделать вашу реализацию однопоточной?

Приложение чата с широковещательной рассылкой

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

Для этого мы используем на сервере широковещательный канал, а для связи клиента с сервером — tokio_websockets.

Создайте новый проект Cargo и добавьте следующие зависимости:

Cargo.toml:

[package]
name = "chat-async"
version = "0.1.0"
edition = "2024"

[dependencies]
futures-util = { version = "0.3.32", features = ["sink"] }
http = "1.4.1"
tokio = { version = "1.52.3", features = ["full"] }
tokio-websockets = { version = "0.13.2", features = ["client", "fastrand", "server", "sha1_smol"] }

Требуемые API

Вам понадобятся следующие функции из tokio и tokio_websockets. Не поленитесь освоить API.

  • StreamExt::next(), реализованный для WebSocketStream: для асинхронного чтения сообщений из WebSocket-потока.
  • SinkExt::send(), реализованный для WebSocketStream: для асинхронной отправки сообщений в WebSocket-поток.
  • Lines::next_line(): для асинхронного чтения сообщений пользователя из стандартного ввода.
  • Sender::subscribe(): для подписки на широковещательный канал.

Два бинарных файла

Обычно в проекте Cargo может быть только один бинарный файл и один файл src/main.rs. В этом проекте нам нужны два бинарных файла: один для клиента и один для сервера. Можно было бы сделать их двумя отдельными проектами Cargo, но мы поместим их в один проект Cargo с двумя бинарными файлами. Чтобы это работало, код клиента и сервера должен находиться в src/bin (см. документацию).

Скопируйте следующий код сервера и клиента в src/bin/server.rs и src/bin/client.rs соответственно. Ваша задача — дополнить эти файлы, как описано ниже.

src/bin/server.rs:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use futures_util::sink::SinkExt;
use futures_util::stream::StreamExt;
use std::error::Error;
use std::net::SocketAddr;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::broadcast::{Sender, channel};
use tokio_websockets::{Message, ServerBuilder, WebSocketStream};

async fn handle_connection(
    addr: SocketAddr,
    mut ws_stream: WebSocketStream<TcpStream>,
    bcast_tx: Sender<String>,
) -> Result<(), Box<dyn Error + Send + Sync>> {

    // TODO: Подсказку см. в описании задачи ниже.

}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
    let (bcast_tx, _) = channel(16);

    let listener = TcpListener::bind("127.0.0.1:2000").await?;
    println!("listening on port 2000");

    loop {
        let (socket, addr) = listener.accept().await?;
        println!("New connection from {addr:?}");
        let bcast_tx = bcast_tx.clone();
        tokio::spawn(async move {
            // Оборачиваем сырой TCP-поток в websocket.
            let (_req, ws_stream) = ServerBuilder::new().accept(socket).await?;

            handle_connection(addr, ws_stream, bcast_tx).await
        });
    }
}

src/bin/client.rs:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use futures_util::SinkExt;
use futures_util::stream::StreamExt;
use http::Uri;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio_websockets::{ClientBuilder, Message};

#[tokio::main]
async fn main() -> Result<(), tokio_websockets::Error> {
    let (mut ws_stream, _) =
        ClientBuilder::from_uri(Uri::from_static("ws://127.0.0.1:2000"))
            .connect()
            .await?;

    let stdin = tokio::io::stdin();
    let mut stdin = BufReader::new(stdin).lines();


    // TODO: Подсказку см. в описании задачи ниже.

}

Запуск бинарных файлов

Запустите сервер командой:

cargo run --bin server

а клиент командой:

cargo run --bin client

Задачи

  • Реализуйте функцию handle_connection в src/bin/server.rs.
    • Подсказка: используйте tokio::select!, чтобы одновременно выполнять две задачи в непрерывном цикле. Одна задача получает сообщения от клиента и рассылает их. Другая отправляет клиенту сообщения, полученные сервером.
  • Дополните функцию main в src/bin/client.rs.
    • Подсказка: как и раньше, используйте tokio::select! в непрерывном цикле для одновременного выполнения двух задач: (1) чтение сообщений пользователя из стандартного ввода и отправка их на сервер, и (2) получение сообщений от сервера и их отображение пользователю.
  • Необязательно: когда закончите, измените код так, чтобы сообщения рассылались всем клиентам, кроме отправителя.

Решения

Обедающие философы — асинхронная версия

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
use tokio::time;

struct Chopstick;

struct Philosopher {
    name: String,
    left_chopstick: Arc<Mutex<Chopstick>>,
    right_chopstick: Arc<Mutex<Chopstick>>,
    thoughts: mpsc::Sender<String>,
}

impl Philosopher {
    async fn think(&self) {
        self.thoughts
            .send(format!("Eureka! {} has a new idea!", &self.name))
            .await
            .unwrap();
    }

    async fn eat(&self) {
        // Продолжаем пытаться, пока не получим обе палочки
        // Берём палочки...
        let _left_chopstick = self.left_chopstick.lock().await;
        let _right_chopstick = self.right_chopstick.lock().await;

        println!("{} is eating...", &self.name);
        time::sleep(time::Duration::from_millis(5)).await;

        // Блокировки освобождаются здесь
    }
}

// Планировщик tokio не приводит к взаимной блокировке с 5 философами, поэтому возьмём 2.
static PHILOSOPHERS: &[&str] = &["Socrates", "Hypatia"];

#[tokio::main]
async fn main() {
    // Создаём палочки
    let mut chopsticks = vec![];
    PHILOSOPHERS
        .iter()
        .for_each(|_| chopsticks.push(Arc::new(Mutex::new(Chopstick))));

    // Создаём философов
    let (philosophers, mut rx) = {
        let mut philosophers = vec![];
        let (tx, rx) = mpsc::channel(10);
        for (i, name) in PHILOSOPHERS.iter().enumerate() {
            let mut left_chopstick = Arc::clone(&chopsticks[i]);
            let mut right_chopstick =
                Arc::clone(&chopsticks[(i + 1) % PHILOSOPHERS.len()]);
            if i == PHILOSOPHERS.len() - 1 {
                std::mem::swap(&mut left_chopstick, &mut right_chopstick);
            }
            philosophers.push(Philosopher {
                name: name.to_string(),
                left_chopstick,
                right_chopstick,
                thoughts: tx.clone(),
            });
        }
        (philosophers, rx)
        // tx освобождается здесь, поэтому нам не нужно явно освобождать его позже
    };

    // Пусть они думают и едят
    for phil in philosophers {
        tokio::spawn(async move {
            for _ in 0..100 {
                phil.think().await;
                phil.eat().await;
            }
        });
    }

    // Выводим их мысли
    while let Some(thought) = rx.recv().await {
        println!("Here is a thought: {thought}");
    }
}

Приложение чата с широковещательной рассылкой

src/bin/server.rs:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use futures_util::sink::SinkExt;
use futures_util::stream::StreamExt;
use std::error::Error;
use std::net::SocketAddr;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::broadcast::{Sender, channel};
use tokio_websockets::{Message, ServerBuilder, WebSocketStream};

async fn handle_connection(
    addr: SocketAddr,
    mut ws_stream: WebSocketStream<TcpStream>,
    bcast_tx: Sender<String>,
) -> Result<(), Box<dyn Error + Send + Sync>> {

    ws_stream
        .send(Message::text("Welcome to chat! Type a message".to_string()))
        .await?;
    let mut bcast_rx = bcast_tx.subscribe();

    // Непрерывный цикл для одновременного выполнения двух задач: (1) получения
    // сообщений из `ws_stream` и их рассылки, и (2) получения
    // сообщений в `bcast_rx` и отправки их клиенту.
    loop {
        tokio::select! {
            incoming = ws_stream.next() => {
                match incoming {
                    Some(Ok(msg)) => {
                        if let Some(text) = msg.as_text() {
                            println!("From client {addr:?} {text:?}");
                            bcast_tx.send(text.into())?;
                        }
                    }
                    Some(Err(err)) => return Err(err.into()),
                    None => return Ok(()),
                }
            }
            msg = bcast_rx.recv() => {
                ws_stream.send(Message::text(msg?)).await?;
            }
        }
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
    let (bcast_tx, _) = channel(16);

    let listener = TcpListener::bind("127.0.0.1:2000").await?;
    println!("listening on port 2000");

    loop {
        let (socket, addr) = listener.accept().await?;
        println!("New connection from {addr:?}");
        let bcast_tx = bcast_tx.clone();
        tokio::spawn(async move {
            // Оборачиваем сырой TCP-поток в websocket.
            let (_req, ws_stream) = ServerBuilder::new().accept(socket).await?;

            handle_connection(addr, ws_stream, bcast_tx).await
        });
    }
}

src/bin/client.rs:

// Copyright 2024 Google LLC
// SPDX-License-Identifier: Apache-2.0

use futures_util::SinkExt;
use futures_util::stream::StreamExt;
use http::Uri;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio_websockets::{ClientBuilder, Message};

#[tokio::main]
async fn main() -> Result<(), tokio_websockets::Error> {
    let (mut ws_stream, _) =
        ClientBuilder::from_uri(Uri::from_static("ws://127.0.0.1:2000"))
            .connect()
            .await?;

    let stdin = tokio::io::stdin();
    let mut stdin = BufReader::new(stdin).lines();

    // Непрерывный цикл для одновременной отправки и получения сообщений.
    loop {
        tokio::select! {
            incoming = ws_stream.next() => {
                match incoming {
                    Some(Ok(msg)) => {
                        if let Some(text) = msg.as_text() {
                            println!("From server: {}", text);
                        }
                    },
                    Some(Err(err)) => return Err(err),
                    None => return Ok(()),
                }
            }
            res = stdin.next_line() => {
                match res {
                    Ok(None) => return Ok(()),
                    Ok(Some(line)) => ws_stream.send(Message::text(line.to_string())).await?,
                    Err(err) => return Err(err.into()),
                }
            }

        }
    }
}

Добро пожаловать в идиоматичный Rust

Основы Rust познакомили с синтаксисом Rust и основными концепциями. Теперь мы хотим пойти дальше: как эффективно использовать Rust в ваших проектах? Как выглядит идиоматичный Rust?

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

⚠️ Этот курс находится в активной разработке.

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

Расписание

Включая 10-минутные перерывы, это занятие займёт около 14 часов 10 минут. Оно включает:

СегментДлительность
Основы проектирования API3 часа 15 минут
Использование системы типов7 часов 30 минут
Полиморфизм3 часа 5 минут

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

Целевая аудитория

Инженеры, имеющие не менее 2–3 лет опыта программирования на C, C++11 или новее, Java 7 или новее, Python 2 или 3, Go или любом другом подобном императивном языке. Мы не ожидаем опыта работы с более современными или богатыми возможностями языками, такими как Swift, Kotlin, C# или TypeScript.

Основы проектирования API

  • Золотое правило: отдавайте приоритет ясности и читаемости в месте вызова. Люди будут тратить гораздо больше времени на чтение мест вызова, чем на объявления вызываемых функций.
  • Сделайте ваш API предсказуемым
    • Следуйте соглашениям об именовании (регистр и привычная лексика стандартной библиотеки: например, методы должны называться «push», а не «push_back», «is_empty», а не «empty» и т. д.)
    • Знайте типы и трейты-словари стандартной библиотеки и используйте их в своих API. Если что-то похоже на базовый тип или алгоритм, сначала проверьте стандартную библиотеку.
    • Используйте устоявшиеся паттерны проектирования API, которые мы обсудим позже на этом курсе (например, newtype, пары «владеющий тип / тип-представление», обработка ошибок)
  • Пишите содержательные и полезные doc-комментарии (например, не просто повторяйте имя метода с пробелами вместо подчёркиваний, не повторяйте одну и ту же информацию только ради заполнения всех тегов Markdown, приводите примеры использования)

Использование системы типов

  • Краткое повторение перечислений, структур и псевдонимов типов
  • Паттерн newtype и инкапсуляция: разбирайте, а не проверяйте (parse, don’t validate)
  • Трейты-расширения: не используйте паттерн newtype, если хотите добавить дополнительное поведение
  • RAII, охранники области видимости и «бомбы» drop: использование Drop для очистки ресурсов, запуска действий или обеспечения инвариантов
  • Типы-«токены»: заставляют пользователей доказать, что они выполнили определённое действие
  • Паттерн typestate: обеспечивает корректные переходы состояний во время компиляции
  • Использование анализатора заимствований для обеспечения инвариантов, не имеющих отношения к владению памятью
    • OwnedFd/BorrowedFd в стандартной библиотеке
    • Branded-типы

Не боритесь с анализатором заимствований

  • Типы «владеющие» и типы-«представления»: &str и String, Path и PathBuf и т. д.
  • Не скрывайте требования к владению: избегайте скрытого .clone(), научитесь любить Cow
  • Разделяйте типы по границам владения
  • Структурируйте иерархию владения как дерево
  • Стратегии управления циклическими зависимостями: подсчёт ссылок, использование индексов вместо ссылок
  • Внутренняя изменяемость (Cell, RefCell)
  • Работа с параметрами времён жизни в пользовательских типах данных

Полиморфизм в Rust

  • Краткое повторение трейтов и обобщённых функций
  • В Rust нет наследования: каковы последствия?
    • Использование перечислений для полиморфизма
    • Использование трейтов для полиморфизма
    • Использование композиции
    • Как выбрать наиболее подходящий паттерн?
  • Работа с обобщениями
    • Обобщённый параметр типа в функции или объект трейта в качестве аргумента?
    • Ограничения трейтов не обязаны ссылаться на обобщённый параметр
    • Параметры типа в трейтах: должен ли это быть обобщённый параметр или ассоциированный тип?
  • Макросы: ценный инструмент, чтобы избежать повторений (DRY), когда трейтов недостаточно (или они слишком сложны)

Обработка ошибок

  • Для чего нужны ошибки? Восстановление или сообщение о проблеме.
  • Result и Option
  • Проектирование хороших ошибок:
    • Определите область действия ошибки.
    • Добавляйте дополнительный контекст по мере подъёма ошибки вверх, пересекая границы областей видимости.
    • Используйте трейт Error, чтобы отслеживать всю цепочку ошибок.
    • Используйте thiserror, чтобы сократить шаблонный код при определении типов ошибок.
    • anyhow
  • Отличайте фатальные ошибки от восстанавливаемых, используя Result<Result<T, RecoverableError>, FatalError>.

Основы проектирования API

Этот сегмент займёт около 3 часов 15 минут. Он включает:

СлайдДлительность
Основы проектирования API2 минуты
Содержательные doc-комментарии1 час 25 минут
Предсказуемый API1 час 50 минут

Содержательные doc-комментарии

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// API для клиента // ❌ Недостаточно деталей
pub mod client {}

/// Функция из A в B // ❌ Избыточно
fn a_to_b(a: A) -> B {...}
 
/// Подключается к базе данных. // ❌ Недостаточно деталей
fn connect() -> Result<(), Error> {...}
This slide and its sub-slides should take about 85 minutes.
  • Doc-комментарии — самая распространённая форма документации, с которой работают разработчики.

  • Хорошие doc-комментарии дают информацию, которую не могут дать код, имена и типы, не повторяя очевидного.

Для кого вы пишете?

Для коллег, соавторов, в основном молчаливых пользователей API или только для себя?

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

// эксперт пишет для экспертов
/// Канонизирует MIR для анализатора заимствований.
///
/// Этот проход гарантирует, что все заимствования соответствуют ограничениям NLL-Polonius
/// до перехода к трансляции MIR в LLVM IR.  
pub fn canonicalize_mir(mir: &mut Mir) {
    // ...
}

// эксперт пишет для новичков
/// Подготавливает промежуточное представление среднего уровня (MIR) для анализа заимствований.
///
/// Анализатор заимствований работает с упрощённой «канонической» формой MIR.
/// Эта функция выполняет такое преобразование. Оно является предварительным условием для
/// последних этапов генерации кода.
///
/// Подробнее о промежуточных представлениях Rust см.
/// [rustc-dev-guide](https://rustc-dev-guide.rust-lang.org/mir/index.html).  
pub fn canonicalize_mir(mir: &mut Mir) {
    // ...
}
  • Справка: проклятие знания — когнитивное искажение, при котором эксперты полагают, что у других такой же уровень знаний и такой же взгляд на вещи.

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

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

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

    Держите этого человека в уме, когда думаете о том, какие части кодовой базы нуждаются во внимании в doc-комментариях.

  • Для кого вы пишете?

  • Также представьте себе версию себя или других людей, которым трудно найти важные детали в запутанных, объёмных doc-комментариях. Не давайте слишком много информации.

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

  • Всегда учитывайте: эксперты тоже читают документацию API. Doc-комментарии могут быть не лучшим местом для объяснения основ вашей предметной области. В таком случае укажите направление (signpost) и назовите ключевые понятия (name-drop). Направьте людей к подробной документации.

Документация библиотеки и приложения

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

  • Код библиотеки:

    • имеет большое число пользователей,
    • решает целый ряд связанных задач,
    • часто имеет стабильные API.
  • Код приложения — наоборот:

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

  • Фундаментальный библиотечный код часто имеет подробную документацию, например стандартная библиотека и многократно переиспользуемые фреймворки вроде Serde и Tokio. Команды, отвечающие за такой код, обычно имеют ресурсы, чтобы писать и поддерживать подробную документацию.

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

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

Устройство doc-комментария

  1. Краткое резюме в одно предложение.
  2. Более подробное объяснение.
  3. Специальные разделы: примеры кода, паники, ошибки, предусловия безопасности.
// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Разбирает пару «ключ-значение» из строки.
///
/// Входная строка должна быть в формате `key=value`. Всё до первого
/// '=' считается ключом, а всё после --- значением.
///
/// # Examples
///
/// ```
/// use my_crate::parse_key_value;
/// let (key, value) = parse_key_value("lang=rust").unwrap();
/// assert_eq!(key, "lang");
/// assert_eq!(value, "rust");
/// ```
///
/// # Panics
///
/// Вызывает панику, если входная строка пуста.
///
/// # Errors
///
/// Возвращает `ParseError::Malformed`, если строка не содержит `=`.
///
/// # Safety
///
/// Вызывает неопределённое поведение, если...
unsafe fn parse_key_value(s: &str) -> Result<(String, String), ParseError>

enum ParseError {
    Empty,
    Malformed,
}
  • Идиоматичные doc-комментарии Rust следуют общепринятой структуре, которая облегчает их чтение разработчиками.

  • Первая строка doc-комментария — однопредложное резюме функции. Делайте её краткой. rustdoc и другие инструменты строго ожидают этого: её используют как краткое описание в документации модуля и в результатах поиска.

  • Далее можно дать длинное, многоабзацное описание «зачем» и «что» делает функция. Используйте Markdown.

  • Наконец, можно использовать заголовки разделов верхнего уровня, чтобы организовать содержимое. В doc-комментариях обычно используются # Examples, # Panics, # Errors и # Safety как заголовки разделов. Сообщество Rust ожидает видеть в этих разделах описание релевантных аспектов вашего API.

  • Rust сильно ориентирован на безопасность и корректность. Документирование поведения вашего кода в случае ошибок имеет решающее значение для написания надёжного ПО.

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

    • Вопрос: спросите класс, почему документирование паник так важно в языке, который предпочитает возвращать Result.

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

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

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

Упоминание ключевых слов и навигация по темам

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Разобранное представление [заголовка записи MARC 21][leader].
///
/// Заголовок MARC содержит метаданные, которые определяют, как интерпретировать остальную часть
/// записи.
///
/// [leader]: https://www.loc.gov/marc/bibliographic/bdleader.html
pub struct Leader {
    /// Определяет схему и набор допустимых последующих полей данных.
    ///
    /// Кодируется в байте 6 заголовка.  
    pub type_of_record: char,

    /// Указывает, нужно ли разбирать поля связей, например «773 Host
    /// Item Entry» для статьи внутри более крупного труда.
    ///
    /// Кодируется в байте 7 заголовка.  
    pub bibliographic_level: char,
    // ... остальные поля
}

/// Разбирает [заголовок записи MARC 21][leader].
///
/// Заголовок кодируется как поле фиксированной длины в 24 байта, содержащее метаданные,
/// которые определяют семантическую интерпретацию остальной части записи.
///
/// [leader]: https://www.loc.gov/marc/bibliographic/bdleader.html
pub fn parse_leader(leader_bytes: &[u8; 24]) -> Result<Leader, MarcError> {
    todo!()
}

#[derive(Debug)]
pub enum MarcError {}
  • Мотивация: читатели документации не будут внимательно читать большинство ваших doc-комментариев так, как читают диалоги в любимом романе.

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

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

  • Спросите класс: что вы ищете в документации? Сосредоточьтесь здесь на поиске информации в данный момент, а не на общих ценностях документации.

  • Называйте ключевые слова ближе к началу абзаца.

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

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

  • Указывайте направление, но не объясняйте слишком подробно.

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

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

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

    Практическое правило: разработчики API должны спрашивать себя: «Если новичок столкнётся с тем, что я документирую, какие источники он найдёт и есть ли там ложные следы, по которым он может пойти?»

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

  • То, что мы уже рассмотрели — предсказуемость API, включая соглашения об именовании, — тоже является формой указания направления.

Избегаем избыточности

Имена и сигнатуры типов передают много информации; не повторяйте её в комментариях!

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

// Повторяет информацию об имени и типе. Можно опустить!
/// Разбирает ipv4 из str. Возвращает Option для случаев ошибки.
fn parse_ip_addr_v4(input: &str) -> Option<IpAddrV4> { ... }

// Повторяет информацию, очевидную из имени поля. Можно опустить!
struct BusinessAsset {
    /// Идентификатор клиента.
    customer_id: u64,
}

// Сначала упоминается имя типа, так делать нельзя!
/// `ServerSynchronizer` --- оркестратор, который отправляет локальные правки [...]
struct ServerSynchronizer { ... }

// Лучше! Сосредоточено на назначении.
/// Отправляет локальные правки [...]
struct ServerSynchronizer { ... }

// Сначала упоминается имя функции, так делать нельзя!
/// `sync_to_server` отправляет локальные правки [...]
fn sync_to_server(...)

// Лучше! Сосредоточено на функции.
/// Отправляет локальные правки [...]
fn sync_to_server(...)
  • Мотивация: документация, которая просто повторяет информацию об имени и сигнатуре, не даёт пользователю API ничего нового.

    Кроме того, информация о сигнатуре может со временем измениться, а документация не будет обновлена соответственно!

  • Это понятная ловушка!

    Наивный подход «всегда документируй свой код» следует этому совету буквально, но не его смыслу.

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

  • Учитывайте назначение разных видов документации:

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

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

  • Имя элемента — часть его документации.

    Аналогично, сигнатура функции — часть её документации.

    Поэтому некоторые аспекты элемента уже раскрыты, когда вы начинаете писать doc-комментарии!

    Не повторяйте информацию ради пункта в списке.

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

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

  • Не объясняйте основы Rust или стандартной библиотеки. Считайте, что читатель имеет средний уровень владения самим языком. Сосредоточьтесь на документировании вашего API.

    Например, если ваша функция возвращает Result, не нужно объяснять, как работают Result или оператор вопросительного знака.

Для дальнейшего изучения

  • Линт #![warn(missing_docs)] может помочь обеспечить наличие doc-комментариев, но он создаёт большую нагрузку на разработчиков, что может привести к тому, что они будут писать комментарии низкого качества.

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

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

// плохо
/// Возвращает фьючерс, который завершается при завершении операции.  
fn sync_to_server() -> Future<Bool>;

// хорошо
/// Отправляет локальные правки на сервер, перезаписывая параллельные правки,
/// если они были.  
fn sync_to_server() -> Future<Bool>;

// плохо
/// Возвращает ошибку, если отправка письма не удалась.  
fn send(&self, email: Email) -> Result<(), Error>;

// хорошо
/// Ставит письмо в очередь на фоновую доставку и сразу возвращает управление.
///
/// Сразу возвращает ошибку, если письмо составлено неправильно.  
fn send(&self, email: Email) -> Result<(), Error>;
  • Мотивация: авторы API могут слишком полагаться на идею, что имени функции и сигнатуры достаточно как документации.

  • Ещё раз: имена и типы являются частью документации. Но они не всегда дают полную картину!

  • Учитывайте поведение функций, которое не отражено в имени функции, именах параметров или её сигнатуре.

    • Неочевидно, что sync_to_server() может что-то перезаписать (что приведёт к потере данных), поэтому это нужно задокументировать.

    • В примере с письмом неочевидно, что функция может вернуть успех и всё равно не доставить письмо.

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

Почему и что, а не как и где

Не документируйте несущественные детали, которые могут часто меняться.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

// плохо
/// Сохраняет запись `User` в базе данных Postgres.
///
/// Эта функция открывает новое соединение и начинает транзакцию. Она проверяет,
/// существует ли пользователь с данным ID, с помощью запроса `SELECT`. Если пользователь
/// не найден, выполняет `INSERT`.
///
/// # Errors
///
/// Возвращает ошибку, если любая операция с базой данных завершилась неудачно.  
pub fn save_user(user: &User) -> Result<(), db::Error> {
    // ...
}

// хорошо
/// Атомарно сохраняет запись пользователя.
///
/// # Errors
///
/// Возвращает ошибку `db::Error::DuplicateUsername`, если пользователь (с ключом из
/// поля `user.username`) уже существует.  
pub fn save_user(user: &User) -> Result<(), db::Error> {
    // ...
}
  • Мотивация: пользователи хотят знать контракт API (что гарантировано для этой функции), а не детали реализации.

  • Мотивация: комментарии, которые объясняют детали реализации, устаревают быстрее, чем комментарии, которые объясняют контракт.

    Внутренняя информация, скорее всего, не важна для пользователя. Представьте, что в doc-комментарии к функции вы объясняете, что для решения задачи используете циклы for: какой смысл в этой информации?

  • Возможно, реализацию нужно объяснить, но, скорее всего, на самом деле нужно объяснить эффекты или инварианты, о которых пользователь API должен знать.

    Сосредоточьтесь на этих эффектах и инвариантах, а не на самих деталях реализации.

    Повторим: детали реализации могут и будут меняться, поэтому их не нужно объяснять.

  • Не пишите о том, где что-то используется: это ещё одна информация, которая быстро устаревает.

Упражнение: диалог о деталях

Ненужные детали иногда указывают на то, что действительно нуждается в документации.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Сортирует срез. Реализовано с помощью рекурсивной быстрой сортировки.
fn sort_quickly<T: Ord>(to_sort: &mut [T]) { ... }
  • Спросите класс: нужен ли этот комментарий для данной функции?

  • Сюжет: играя роль посредника между классом и автором, например менеджера продукта или руководителя, скажите классу, что автор этой функции возражает.

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

    Если класс спросит, почему автор возражает, пока не давайте подробностей.

  • Спросите класс: зачем вызывающему коду нужно знать используемый алгоритм сортировки?

  • Сюжет: «вернитесь» после встречи с исходным автором и объясните классу, что эта функция — код приложения, который вызывается для недоверенных данных, которые могут быть специально составлены так, чтобы вызвать квадратичное поведение при сортировке.

  • Спросите класс: теперь у нас больше подробностей; как нам прокомментировать эту функцию?

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

    Учтите, объясняет ли комментарий, что используется цикл for (ненужная деталь), или он объясняет, что внутренние алгоритмы имеют известные уязвимости (документация привлекает внимание не к тому).

Предсказуемый API

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

/* Какие трейты должен реализовывать этот тип? */
pub struct ApiToken(String);

impl ApiToken {
    // Как должен называться этот метод?
    pub unsafe fn ____(String) -> ApiToken;
}
This slide and its sub-slides should take about 107 minutes.
  • Предсказуемый API — такой, в котором пользователь может делать предположения о части API на основе поверхностных деталей, таких как имена, типы и сигнатуры.

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

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

Соглашения об именовании

- One core component of readability and predictability is the way function names are composed.

Формальное и последовательно применяемое соглашение об именовании позволяет разработчикам воспринимать имена как предметно-ориентированный язык и быстро понимать функциональность и варианты использования метода.

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

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

new: функции-конструкторы

В Rust нет ключевого слова new; вместо этого new — распространённый префикс или целое имя метода.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

impl<T> Vec<T> {
    fn new() -> Vec<T>;
}

impl<T> Box<T> {
    fn new(T) -> Box<T>;
}
  • Для инициализации нового значения у Rust нет ключевого слова new, есть только функции, которые вы вызываете, или значения, которые вы заполняете напрямую.

    new — общепринятое имя для функции-конструктора «по умолчанию» типа. Никакого особого синтаксического значения оно не имеет.

    Иногда это префикс, иногда функция принимает аргументы.

is_[условие]: проверка булева значения

Проверяет условие о типе данных.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

impl<T> Vec<T> {
    fn is_empty(&self) -> bool;
}

impl f32 {
    fn is_nan(self) -> bool;
}

impl u32 {
    fn is_power_of_two(self) -> bool;
}
  • Булево условие о значении.

  • Префикс is предпочтительнее методов с not в имени. В методах стандартной библиотеки нет is_not_; используйте !value.is_[условие].

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

Суффикс для методов в стиле доступа.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

impl<T> Vec<T> {
    fn get(&self, index: usize) -> Option<&T>;
    fn get_mut(&mut self, index: usize) -> Option<&mut T>;
}

impl<T> [T] {
    fn iter(&self) -> impl Iterator<Item = &T>;
    fn iter_mut(&mut self) -> impl Iterator<Item = &mut T>;
}
  • Суффикс, который обозначает, что метод даёт доступ к изменяемой ссылке.

  • Требует изменяемого доступа к значению, у которого вызывается этот метод.

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

with как конструктор

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

with как в «<Тип> с конкретной настройкой».

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

impl<T> Vec<T> {
    // Инициализирует память для как минимум N элементов, len по-прежнему 0.
    fn with_capacity(capacity: usize) -> Vec<T>;
}
  • with может быть префиксом конструктора, чаще всего при инициализации памяти в куче для типов-контейнеров.

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

  • Спросите класс: почему не from_capacity?

    Ответ: Vec::with_capacity как вызов метода хорошо читается как «Vec с ёмкостью». Подумайте, как Vec::new_capacity или Vec::from_capacity читаются на письме: они плохо передают смысл происходящего.

with как копирование с изменением

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

with в смысле «как <значение>, но с чем-то другим».

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

impl Path {
    // Упрощённо. "/home/me/mortgage.pdf".with_extension("mov") =>
    // "/home/me/mortgage.mov"
    fn with_extension(&self, ext: &OsStr) -> PathBuf;
}
  • with можно использовать для методов, которые копируют значение, а затем меняют определённую его часть.

    В этом примере with_extension копирует данные &Path в новый PathBuf, но меняет расширение на другое.

    Исходный Path остаётся неизменным.

with: работа с замыканиями

with в смысле «сделай X, но таким конкретным способом вычисления».

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

impl<T> Vec<T> {
    // Упрощённо. Если изменение размера больше текущего размера vec, используем
    // замыкание для заполнения элементов.
    pub fn resize_with(&mut self, new_len: usize, f: impl FnMut() -> T);
}

mod iter {
    // Создаём бесконечный ленивый итератор с помощью замыкания.
    pub fn repeat_with<A, F: FnMut() -> A>(repeater: F) -> RepeatWith<F>;
}
  • with может стоять в конце как суффикс, чтобы сообщить, что вместо «разумного значения по умолчанию» для вычисления можно использовать конкретную функцию или замыкание.

    Похоже на by.

try_[метод]: методы, которые могут завершиться ошибкой, с конкретными ошибками

Префикс для методов, которые могут завершиться ошибкой и вернуть Result.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

impl TryFrom<i32> for u32 {
    type Error = TryFromIntError;

    fn try_from(value: i32) -> Result<i64, TryFromIntError>;
}

impl<T> Receiver<T> {
    fn try_recv(&self) -> Result<T, TryRecvError>;
}
  • Префикс для методов, которые могут завершиться неудачей, возвращая Result.

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

  • Вопрос: почему Vec::get и другие похожие методы не называются try_get?

    Методы называются get, если возвращают ссылку на существующее значение, и возвращают Option, а не Result, потому что есть только один вид сбоя. Например, для Vec::get это только «индекс вне границ», а для HashMap::get — «ключа не существует».

from

Функция-конструктор, которая сильно намекает на «преобразование типов».

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

impl Duration {
    fn from_days(days: u64) -> Duration;
}

impl i32 {
    fn from_ascii(src: &[u8]) -> Result<i32, ParseIntError>;
}

impl u32 {
    fn from_le_bytes(bytes: [u8; 4]) -> u32;
}
  • Префикс для функций в стиле конструктора и трейта From.

  • Эти функции могут принимать несколько аргументов, но обычно подразумевают, что пользователь делает больше работы, чем при обычном конструкторе.

  • Для большинства функций в стиле конструктора по-прежнему предпочитается new; для from подразумевается преобразование одного типа данных в другой.

into

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub trait IntoIterator {
    fn into_iter(self) -> Self::IntoIter;
}

impl str {
    fn into_string(self: Box<str>) -> String;
}
  • Префикс для функции, которая потребляет значение, владеющее данными, и преобразует его в значение другого типа.

  • Это не приведение с переинтерпретацией! Данные могут быть переупорядочены, перераспределены и изменены любым образом, в том числе с потерей информации.

  • into_iter потребляет коллекцию (например, вектор, BTreeSet или HashMap) и возвращает итератор по значениям, владеющим данными, в отличие от iter и iter_mut, которые возвращают итераторы по ссылкам.

to: непотребляющее преобразование

Префикс функции, которая принимает заимствованное значение и создаёт значение, владеющее данными.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

impl str {
    fn to_owned(&self) -> String;

    fn to_uppercase(&self) -> String;
}

impl u32 {
    // Берём `self` по значению, потому что `u32` реализует `Copy`
    fn to_be(self) -> u32;
}
  • Методы, которые создают новое значение, владеющее данными, не потребляя self, и подразумевают преобразование типа, называются с префиксом to.

  • Методы, начинающиеся с «to», возвращают другой тип и сильно подразумевают нетривиальное преобразование типа или даже преобразование данных. Например, str::to_uppercase.

  • Методы «to» чаще всего принимают &self. Однако они могут принимать self по значению, если тип реализует Copy: это также гарантирует, что вызов метода преобразования не потребляет self.

  • Если нужно определить метод, который принимает &self и возвращает значение того же типа, владеющее данными, реализуйте Clone или ToOwned.

  • Если нужно определить метод, который потребляет исходное значение, используйте шаблон именования «into».

  • Также встречается в функциях, которые меняют порядок байтов примитивов, или копируют и показывают значение newtype.

Для дальнейшего изучения

  • Спросите класс: в чём разница между to_owned и into_owned?

    Ответ: to_owned встречается у значений-ссылок, таких как &str, а into_owned — у значений, владеющих данными, которые содержат ссылочные типы, например Cow (copy-on-write).

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

as_ и _ref: преобразования ссылок

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

impl<T> Rc<T> {
    // Очень распространено у типов-контейнеров, посмотрите, есть и у Option.
    fn as_ref(&self) -> &T;

    fn as_ptr(&self) -> *const T;
}

impl<T> Option<T> {
    fn as_ref(&self) -> Option<&T>;

    fn as_slice(&self) -> &[T];
}
  • Метод, который возвращает заимствование основного фрагмента содержащихся данных.

  • Отношение заимствования чаще всего прямолинейно: возвращаемое значение — ссылка, которая заимствует self.

  • Возвращаемое значение может заимствовать self только логически; например, методы as_ptr() возвращают небезопасный указатель. Анализатор заимствований не отслеживает заимствования для указателей.

  • Тип, реализующий метод as, должен содержать один основной фрагмент данных, который заимствуется наружу.

    • Соглашение об именовании as не работает, если тип данных — агрегат многих полей без явного основного.

    • Если у вас есть два геттера ссылок, которые нужно различать, используйте суффикс _ref.

by: пользовательский компаратор или проекция

Компонент для методов, которые принимают пользовательскую функцию проекции или сравнения.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

impl<T> [T] {
    fn sort(&mut self) where T: Ord;

    fn sort_by(&mut self, compare: impl FnMut(&T, &T) -> Ordering);

    fn sort_by_key<K, F>(&mut self, f: F)
    where
        F: FnMut(&T) -> K,
        K: Ord;
}
  • sort_by принимает пользовательскую функцию-компаратор, которая заменяет обычную логику сравнения Ord.

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

  • Иногда предлог «by» — просто предлог:

Упражнение

  1. Что, по мнению этих имён, делают эти методы?
  2. Какими должны быть имена для этих сигнатур?
// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

// Какие типы у этих методов?
Option::is_some // ?
slice::get // ?
slice::get_unchecked_mut // ?
Option::as_ref // ?
str::from_utf8_unchecked_mut // ?
Rc::get_mut // ?
Vec::dedup_by_key // ?

// Как должны называться методы с такими типами?
fn ____(String) -> Self;
fn ____(&self) -> Option<&InnerType>; // детали InnerType не важны.
fn ____(self, String) -> Self;
fn ____(&mut self) -> Option<&mut InnerType>;
  • Пройдитесь вместе с классом по методам из примера и обсудите, каковы должны быть типы функций.

  • Пройдитесь по безымянным методам и придумайте, какие имена им следует дать.

    Ответы для пропущенных типов:

    • Option::is_some(&self) -> bool

    • slice::get(&self /* &[T] */, usize) -> Option<&T>

    • slice::get_unchecked_mut(&self /* &[T] */, usize) -> &T (небезопасный и упрощённый)

    • Option::as_ref(&self /* &Option<T> */) -> Option<&T>

    • str::from_utf8_unchecked_mut(v: &mut [u8]) -> &mut str (небезопасный)

    • Rc::get_mut(&mut self /* &mut Rc<T> */) -> Option<&mut T> (упрощённый)

    • Vec::dedup_by_key<K: PartialEq>(&mut self /* &mut Vec<T> */, key: impl FnMut(&mut T) -> K) (упрощённый) Ответы для пропущенных имён:

    • fn from_string(String) -> Self

    • fn inner(&self) -> Option<&InnerType> или as_ref, в зависимости от контекста

    • fn with_string(self, String) -> Self

    • fn inner_mut(&mut self) -> Option<&mut InnerType> или as_ref_mut, в зависимости от контекста

Общие трейты для реализации

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone /* ... */)]
pub struct MyData {
    pub name: String,
    pub number: usize,
    pub data: [u8; 64],
}
  • Трейты — один из самых мощных инструментов языка Rust. Язык и экосистема ожидают, что вы будете их использовать, поэтому значительная часть предсказуемости — это то, какие трейты реализованы для типа!

  • Трейты стоит щедро реализовывать для типов, которые вы определяете, но есть оговорки!

  • Помните, что многие трейты можно получить через derive: заставить компилятор-плагин (макрос) написать реализацию за вас!

  • Авторы трейтов экосистемы (например, De/Serialize) предоставили реализации derive для пользователей, поэтому для реализации таких трейтов от разработчика требуется очень мало усилий!

Debug

Трейт «запись в строку» для отладочных целей.

Можно получить через derive: ✅

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug)]
pub struct Date {
    day: u8,
    month: u8,
    year: i64,
}

#[derive(Debug)]
pub struct User {
    name: String,
    date_of_birth: Date,
}

pub struct PlainTextPassword {
    password: String,
    hint: String,
}

impl std::fmt::Debug for PlainTextPassword {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("PlainTextPassword")
            .field("hint", &self.hint)
            .field("password", &"[omitted]")
            .finish()
    }
}

fn main() {
    let user = User {
        name: "Алиса".to_string(),
        date_of_birth: Date { day: 31, month: 10, year: 2002 },
    };

    println!("{user:?}");
    println!(
        "{:?}",
        PlainTextPassword {
            password: "Password123".to_string(),
            hint: "Used it for years".to_string()
        }
    );
}
  • Предоставляет простейшую функциональность «записи в строку».

  • Форматирование отладочной информации для программистов во время разработки, а не для внешнего вида или сериализации.

  • Позволяет использовать {:?} и {:#?} в макросах форматирования строк.

  • Когда не получать через derive или не реализовывать: если структура хранит чувствительные данные, подумайте, стоит ли реализовывать для неё Debug.

    • Если Debug нужен, рассмотрите ручную реализацию Debug вместо derive. Исключите чувствительные данные из реализации.

Display

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

Можно получить через derive: ❌, без крейтов вроде derive_more.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug)]
pub enum NetworkError {
    HttpCode(u16),
    WhaleBitTheUnderseaCable,
}

impl std::fmt::Display for NetworkError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            NetworkError::HttpCode(code) => write!(f, "HTTP Error code {code}"),
            NetworkError::WhaleBitTheUnderseaCable => {
                write!(f, "Whale attack detected – call Ishmael")
            }
        }
    }
}

fn main() {
    let http = NetworkError::HttpCode(404);
    let whale = NetworkError::WhaleBitTheUnderseaCable;

    println!("http debug: {:?}", http);
    println!("http display: {}", http);
    println!("whale debug: {:?}", whale);
    println!("whale display: {}", whale);
}
  • Трейт, похожий на Debug, но с акцентом на удобочитаемость для конечного пользователя.

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

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

  • Типы, которые реализуют Display, автоматически получают реализацию ToString.

  • Сравните поведение Debug и Display для нашего примера типа ошибки. Покажите, что реализация Debug более прямо отображает данные так, как они выглядят в коде, тогда как Display даёт более дружелюбное сообщение для непрограммистов.

PartialEq и Eq

Частичное равенство и полное равенство.

Можно получить через derive: ✅

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(PartialEq, Eq)]
pub struct User { name: String, favorite_number: i32 }

fn main() {
    let alice = User { name: "alice".to_string(), favorite_number: 1_000_042 };
    let bob = User { name: "bob".to_string(), favorite_number: 42 };

    dbg!(alice == alice);
    dbg!(alice == bob);
}
  • Методы, связанные с равенством. Если тип реализует PartialEq, то с этим типом можно использовать оператор ==/!=.

  • Тип не может реализовать Eq без реализации PartialEq.

  • Напоминание: «partial» означает, что для этой функции в множестве есть недопустимые члены.

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

    Например, для значений с плавающей запятой NaN — исключение: NaN == NaN ложно, несмотря на побитовое равенство.

    PartialEq существует, чтобы отделить такие типы, как f32/f64, от типов с полным равенством.

  • Можно реализовать PartialEq между разными типами, но это в основном полезно для типов-ссылок и умных указателей.

PartialOrd и Ord

Частичный порядок и полный порядок.

Можно получить через derive: ✅

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(PartialEq, PartialOrd)]
pub struct Partially(f32);

#[derive(PartialEq, Eq, PartialOrd, Ord)]
pub struct Totally {
    id: u32,
    name: String,
}

fn main() {
    let a = Totally { id: 0, name: "alice".into() };
    let b = Totally { id: 1, name: "alice".into() };
    let c = Totally { id: 0, name: "charlie".into() };

    dbg!(a.cmp(&b));
    dbg!(a.cmp(&c));
}
  • Методы, связанные со сравнением. Если тип реализует PartialOrd/Ord, то с этим типом можно использовать операторы сравнения (<, <=, >, >=).

  • Ord даёт доступ к методам min, max и clamp.

  • При получении через derive сравнивает в порядке определения.

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

    Для структур это значит, что поля сравниваются в порядке записи, так что поля id сравниваются раньше полей name в Totally.

  • Предусловия: PartialEq для PartialOrd, Eq для Ord.

    Чтобы реализовать Ord, тип также должен реализовать PartialEq, Eq и PartialOrd.

  • Как и в случае с PartialEq и Eq, тип не может реализовать Ord без реализации PartialOrd.

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

  • Используется в алгоритмах сортировки и поиска и для поддержания порядка в типах данных вроде BTreeMap/BTreeSet.

Hash

Вычисление хеша для типа.

Можно получить через derive: ✅

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::collections::HashMap;

#[derive(PartialEq, Eq, Hash)]
pub struct User {
    id: u32,
    name: String,
}

fn main() {
    let user = User { id: 1, name: "Алиса".into() };
    let mut map = HashMap::new();
    map.insert(user, "value");
}
  • Позволяет использовать тип в алгоритмах хеширования; чаще всего используется со структурами данных вроде HashMap.

  • Очень упрощает использование собственных типов как ключей в HashMap!

  • Hash сам не определяет логику хеширования; вместо этого он передаёт данные типа в Hasher. Это позволяет использовать разные алгоритмы хеширования, не меняя реализацию Hash для типа.

Clone

Глубокое копирование типа или дублирование умного разделяемого указателя.

Можно получить через derive: ✅

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::collections::BTreeSet;
use std::rc::Rc;

#[derive(Clone)]
pub struct LotsOfData {
    string: String,
    vec: Vec<u8>,
    set: BTreeSet<u8>,
}

fn main() {
    let lots_of_data = LotsOfData {
        string: "String".to_string(),
        vec: vec![1; 255],
        set: BTreeSet::from_iter([1, 2, 3, 4, 5, 6, 7, 8]),
    };

    // Глубокое копирование всех данных в `lots_of_data`.
    let lots_of_data_cloned = lots_of_data.clone();

    let reference_counted = Rc::new(lots_of_data);

    // Копирует указатель с подсчётом ссылок, а не значение.
    let reference_copied = reference_counted.clone();
}
  • «Глубоко скопировать» значение или, в случае указателей с подсчётом ссылок, таких как Rc/Arc, создать новый экземпляр этого указателя.

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

Copy

Похоже на Clone, но показывает, что тип можно побитово скопировать.

Можно получить через derive: ✅

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug, Clone, Copy)]
pub struct Copyable(u8, u16, u32, u64);

fn main() {
    let copyable = Copyable(1, 2, 3, 4);
    let copy = copyable; // Неявная операция копирования
    dbg!(copyable);
    dbg!(copy);
}
  • Clone — явная, определяемая пользователем операция копирования. Copy — неявное побитовое копирование.

  • Обычно его стоит реализовывать только для типов «простых данных», которые должны вести себя как примитивные значения. Например, примитивные числовые типы для библиотеки линейной алгебры.

  • Здесь то же предостережение, что и для Clone: если дублирование значений нарушит инвариант, тип не должен реализовывать Copy.

  • Всегда получайте Clone и Copy через derive вместе! Не реализуйте Clone вручную, когда реализуете Copy.

    • Операции копирования не вызывают метод clone, поэтому пользовательская реализация Clone может вести себя иначе, чем неявное копирование. Получение обоих трейтов Clone и Copy через derive гарантирует, что вызов clone даст тот же результат, что и копирование.
  • Нельзя реализовать для типов с Drop или с полями, которые не реализуют Copy.

    • Спросите класс: почему тип с данными в куче (Vec, BTreeMap, Rc и т. п.) не может быть Copy?

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

      Несколько Vec указывали бы на одни и те же данные в памяти. Добавление и удаление данных обновляли бы значения длины и ёмкости только у отдельных Vec. То же самое для BTreeMap.

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

From и Into

Преобразование из одного типа в другой.

Можно получить через derive: ❌, без крейтов вроде derive_more.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub struct Wrapper(String);

impl From<&str> for Wrapper {
    fn from(value: &str) -> Self {
        Wrapper(value.to_owned())
    }
}

impl From<i32> for Wrapper {
    fn from(value: i32) -> Self {
        Wrapper(value.to_string())
    }
}

// `Into` удобнее использовать как ограничение трейта.
fn into_string<S: Into<String>>(s: S) {}
fn string_from<T>(t: T) where String: From<T> {}

fn main() {
    // `Wrapper` можно создать из `&str` и `i32`.
    let a = Wrapper::from("Hello, obvious!");
    let b = Wrapper::from(-123);

    // Реализация `From` подразумевает реализацию `Into`.
    let c: Wrapper = "Hello, implementation!".into();
}
  • Предоставляет типам функциональность преобразования.

  • From предоставляет функцию в стиле конструктора, тогда как Into предоставляет метод для существующего значения.

  • Для типа, который вы создаёте, предпочитайте писать реализации From<T>, а не Into<T>.

    Трейт Into автоматически реализуется для любого типа, который реализует From.

  • Into предпочтительнее как ограничение трейта для аргументов функций, чтобы было понятно, что функция может принимать: T: Into<String> яснее выражает намерение, чем String: From<T>.

TryFrom/TryInto

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

Получается через derive: ❌

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug)]
pub struct InvalidNumber;

#[derive(Debug)]
pub struct DivisibleByTwo(usize);

impl TryFrom<usize> for DivisibleByTwo {
    type Error = InvalidNumber;

    fn try_from(value: usize) -> Result<Self, InvalidNumber> {
        if value.rem_euclid(2) == 0 {
            Ok(DivisibleByTwo(value))
        } else {
            Err(InvalidNumber)
        }
    }
}

fn main() {
    let success: Result<DivisibleByTwo, _> = 4.try_into();
    dbg!(success);
    let fail: Result<DivisibleByTwo, _> = 5.try_into();
    dbg!(fail);
}
  • Предоставляет преобразование, которое может не удаться, возвращая тип результата.

  • Как и From/Into, для типов предпочтительнее реализовывать TryFrom, а не TryInto.

  • В реализациях можно указать тип ошибки для Result.

Трейты в стиле Serialize/Deserialize

Крейты вроде serde могут реализовывать сериализацию автоматически.

Можно получить через derive: ✅

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Serialize, Deserialize)]
struct ExtraData {
    fav_color: String,
    name_of_dog: String,
}

#[derive(Serialize, Deserialize)]
struct Data {
    name: String,
    age: usize,
    extra_data: ExtraData,
}
  • Предоставляет функциональность сериализации и десериализации для типа, позволяя преобразовывать типы данных Rust в форматы данных вроде JSON и обратно.

  • В стандартной библиотеке нет встроенной функциональности сериализации, но крейт serde — стандартный интерфейс сообщества для сериализации.

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

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

Использование системы типов

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

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

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

This slide should take about 5 minutes.

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

  • Система типов Rust во многом заимствует идеи из языков функционального программирования.

    Например, перечисления Rust в языках вроде Haskell и OCaml известны как «алгебраические типы данных». При поиске руководства по проектированию с помощью типов можно черпать вдохновение в материалах для функциональных языков. «Domain Modeling Made Functional» — отличный ресурс по этой теме с примерами на F#.

  • Несмотря на функциональные корни Rust, не все шаблоны функционального проектирования легко переносятся на Rust.

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

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

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

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

Этот сегмент займёт около 7 часов 30 минут. Он включает:

СлайдДлительность
Использование системы типов5 минут
Паттерн newtype20 минут
RAII1 час 50 минут
Трейты-расширения1 час 5 минут
Паттерн typestate1 час 5 минут
Инварианты анализатора заимствований1 час 30 минут
Типы-токены1 час 35 минут

Паттерн newtype

Newtype — обёртка вокруг существующего типа, часто примитивного:

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Уникальный идентификатор пользователя, реализованный как newtype вокруг `u64`.
pub struct UserId(u64);

В отличие от псевдонимов типов, newtype не взаимозаменяем с оборачиваемым типом:

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub struct UserId(u64);

fn needs_user(user: UserId) {
    // ...
}

fn main() {
    needs_user(1); // 🛠️❌
}

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub struct UserId(u64);

fn main() {
    assert_ne!(UserId(1), UserId(2)); // 🛠️❌
}
This slide and its sub-slides should take about 20 minutes.
  • Студенты должны были встретить паттерн newtype на курсе «Основы», когда изучали кортежные структуры.

  • Запустите пример, чтобы показать студентам сообщение об ошибке компилятора.

  • Измените пример так, чтобы использовать псевдоним типа вместо newtype, например type MessageId = u64. Изменённый пример должен компилироваться, что подчеркнёт различия между двумя подходами.

  • Подчеркните, что newtype по умолчанию не имеет связанного с ним поведения. Нужно сознательно решать, какие методы и операторы базового типа вы готовы переадресовывать. В нашем примере UserId разумно разрешить сравнение UserId, но не имеет смысла разрешать арифметические операции вроде сложения или вычитания.

Семантическая путаница

Когда функция принимает несколько аргументов одного типа, места вызова становятся неясными:

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct LoginError;
fn login(username: &str, password: &str) -> Result<(), LoginError> {
    // [...]
    Ok(())
}

fn main() {
    let password = "password";
    let username = "username";

    // В другой части кодовой базы мы по ошибке меняем аргументы местами.
    // Ошибка (в лучшем случае), уязвимость безопасности (в худшем)
    login(password, username);
}

Паттерн newtype может предотвратить этот класс ошибок во время компиляции:

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct Username(String);
struct Password(String);
struct LoginError;

fn login(username: &Username, password: &Password) -> Result<(), LoginError> {
    // [...]
    Ok(())
}

fn main() {
    let password = Password("password".into());
    let username = Username("username".into());
    login(&password, &username); // 🛠️❌
}
  • Запустите оба примера, чтобы показать студентам успешную компиляцию исходного примера и ошибку компилятора, которую выдаёт изменённый пример.

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

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

    #![allow(unused)]
    fn main() {
    // Copyright 2025 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    pub struct LoginArguments<'a> {
        pub username: &'a str,
        pub password: &'a str,
    }
    fn login(i: LoginArguments) {}
    let password = "password";
    let username = "username";
    
    // Не нужно проверять определение функции `login`, чтобы заметить проблему.
    login(LoginArguments {
        username: password,
        password: username,
    })
    }

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

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

Паттерн newtype можно использовать для обеспечения инвариантов.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub struct Username(String);

impl Username {
    pub fn new(username: String) -> Result<Self, InvalidUsername> {
        if username.is_empty() {
            return Err(InvalidUsername::CannotBeEmpty)
        }
        if username.len() > 32 {
            return Err(InvalidUsername::TooLong { len: username.len() })
        }
        Ok(Self(username))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}
pub enum InvalidUsername {
    CannotBeEmpty,
    TooLong { len: usize },
}
  • Паттерн newtype в сочетании с системой модулей и видимости Rust можно использовать, чтобы гарантировать, что экземпляры заданного типа удовлетворяют набору инвариантов.

    В примере выше «сырой» String, хранящийся внутри структуры Username, нельзя получить напрямую из других модулей или крейтов, поскольку он не помечен как pub или pub(in ...). Потребители типа Username вынуждены использовать метод new, чтобы создавать экземпляры. В свою очередь new выполняет проверку, гарантируя, что все экземпляры Username удовлетворяют этим проверкам.

  • Метод as_str позволяет потребителям получить сырое строковое представление (например, чтобы сохранить его в базе данных). Однако потребители не могут изменить базовое значение, поскольку возвращаемый тип &str ограничивает их доступом только для чтения.

  • Инварианты на уровне типов дают вторичные преимущества.

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

Действительно ли это инкапсулировано?

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub struct Username(String);

impl Username {
    pub fn new(username: String) -> Result<Self, InvalidUsername> {
        // Проверки...
        Ok(Self(username))
    }
}

impl std::ops::DerefMut for Username { // ‼️
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.0
    }
}
impl std::ops::Deref for Username {
    type Target = str;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}
pub struct InvalidUsername;
  • DerefMut позволяет пользователям получить изменяемую ссылку на обёрнутое значение.

    Изменяемую ссылку можно использовать для изменения базовых данных способами, которые могут нарушить инварианты, обеспечиваемые Username::new!

  • Аудируя поверхность API newtype, можно сузить область проверки до методов и трейтов, которые предоставляют изменяемый доступ к базовым данным.

  • Напомните студентам о границах приватности.

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

RAII: трейт Drop

RAII (Resource Acquisition Is Initialization, «получение ресурса — это инициализация») связывает время жизни ресурса со временем жизни значения.

Rust использует RAII для управления памятью, а трейт Drop позволяет распространить это на другие ресурсы, например дескрипторы файлов или блокировки.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub struct File(std::os::fd::RawFd);

impl File {
    pub fn open(path: &str) -> Result<Self, std::io::Error> {
        // [...]
        Ok(Self(0))
    }

    pub fn read_to_end(&mut self) -> Result<Vec<u8>, std::io::Error> {
        // [...]
        Ok(b"example".to_vec())
    }

    pub fn close(self) -> Result<(), std::io::Error> {
        // [...]
        Ok(())
    }
}

fn main() -> Result<(), std::io::Error> {
    let mut file = File::open("example.txt")?;
    println!("content: {:?}", file.read_to_end()?);
    Ok(())
}
This slide and its sub-slides should take about 110 minutes.
  • Легко пропустить: file.close() никогда не вызывается. Спросите класс, заметили ли они это.

  • Чтобы корректно освободить дескриптор файла, file.close() нужно вызвать после последнего использования, а также на путях раннего возврата при ошибках.

  • Вместо того чтобы полагаться на пользователя, который вызовет close(), мы можем реализовать трейт Drop, чтобы освобождать ресурс автоматически. Это связывает очистку со временем жизни значения File.

    #![allow(unused)]
    fn main() {
    // Copyright 2025 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    impl Drop for File {
        fn drop(&mut self) {
            // libc::close(...);
            println!("file descriptor was closed");
        }
    }
    }
  • Обратите внимание, что Drop::drop() не может вернуть Result. Любые сбои нужно обрабатывать внутри или игнорировать. В стандартной библиотеке ошибки при закрытии FD внутри Drop молча отбрасываются. См. реализацию: https://doc.rust-lang.org/src/std/os/fd/owned.rs.html#169-196

  • Когда вызывается Drop::drop?

    Обычно, когда переменная file в main() выходит из области видимости (при возврате или из-за паники), drop() вызывается автоматически.

    Если файл перемещается в другую функцию (как в случае с File::close()), значение освобождается, когда эта функция возвращает управление, а не в main.

    Для сравнения: C++ выполняет деструкторы в исходной области видимости даже для значений, из которых было выполнено перемещение.

  • Демонстрация: вставьте panic!("oops") в начало read_to_end() и запустите. drop() всё равно выполнится во время раскручивания стека.

Для дальнейшего изучения

У трейта Drop есть другое важное ограничение: он не async.

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

Drop можно пропустить

Есть случаи, когда деструкторы не выполняются.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug)]
struct OwnedFd(i32);

impl Drop for OwnedFd {
    fn drop(&mut self) {
        println!("OwnedFd::drop() called with raw fd: {:?}", self.0);
    }
}

impl Drop for TmpFile {
    fn drop(&mut self) {
        println!("TmpFile::drop() called with owned fd: {:?}", self.0);
        // libc::unlink("/tmp/file")
        // panic!("TmpFile::drop() panics");
    }
}

#[derive(Debug)]
struct TmpFile(OwnedFd);

impl TmpFile {
    fn open() -> Self {
        Self(OwnedFd(2))
    }

    fn close(&self) {
        panic!("TmpFile::close(): not implemented yet");
    }
}

fn main() {
    let owned_fd = OwnedFd(1);

    let file = TmpFile::open();

    std::process::exit(0);

    // std::mem::forget(file);

    // file.close();

    let _ = owned_fd;
}
  • Drop не гарантированно выполняется всегда. Есть несколько случаев, когда drop пропускается: программа может аварийно завершиться или выйти, значение с реализацией drop может утечь и т. п.

  • В версии, которая вызывает std::process::exit, TmpFile::drop() никогда не выполняется, потому что exit() немедленно завершает процесс без возможности вызова метода drop().

    • Можно предотвратить случайное использование exit, запретив линт clippy::exit.
  • Если удалить строку std::process::exit(0), каждый метод drop() в этом простом случае выполнится по очереди.

  • Попробуйте раскомментировать вызов std::mem::forget. Как вы думаете, что произойдёт?

    mem::forget() забирает владение и «забывает» о значении file, не выполняя его деструктор Drop::drop(). Деструктор owned_fd всё равно выполняется.

  • Удалите вызов mem::forget(), затем раскомментируйте вызов file.close() под ним. Что вы ожидаете теперь?

    При настройке по умолчанию panic = "unwind" стек всё равно раскручивается и деструкторы выполняются, даже если паника начинается в main.

  • Последним шагом раскомментируйте panic! внутри TmpFile::drop() и запустите. Спросите класс: какие деструкторы выполнятся до аварийного завершения?

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

    • Некоторая очистка, уже начатая, всё ещё может завершиться (например, деструкторы полей значения, которое сейчас освобождается),
    • но всё, что запланировано позже на пути раскручивания, может быть пропущено полностью.
    • Поэтому мы говорим, что нельзя полагаться только на drop() для критически важной внешней очистки и нельзя предполагать, что двойная паника прерывает работу без выполнения каких-либо дальнейших деструкторов.
  • Некоторые языки запрещают или ограничивают исключения в деструкторах. В Rust можно вызвать панику в Drop::drop, но это почти никогда не хорошая идея, так как это может нарушить раскручивание стека и привести к непредсказуемой очистке. Лучше избегать этого, если нет очень конкретной необходимости, например в случае drop bomb.

  • Drop подходит для очистки ресурсов в пределах процесса, но он не подходит для гарантий того, что что-то произойдёт за пределами процесса (например, на локальном диске или в другом сервисе распределённой системы).

  • Например, удаление временного файла в drop() допустимо в учебном примере, но в настоящей программе всё равно понадобился бы внешний механизм очистки, например сборщик временных файлов.

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

Mutex и MutexGuard

В предыдущих примерах RAII использовался для управления конкретными ресурсами, такими как дескрипторы файлов. С Mutex «ресурс» — это изменяемый доступ к значению. Вы получаете доступ к значению, вызывая lock, который возвращает MutexGuard, автоматически разблокирующий Mutex при освобождении.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::sync::Mutex;

fn main() {
    let m = Mutex::new(vec![1, 2, 3]);

    let mut guard = m.lock().unwrap();
    guard.push(4);
    guard.push(5);
    println!("{guard:?}");
}
  • Mutex управляет эксклюзивным доступом к значению. В отличие от предыдущих примеров RAII, здесь ресурс логический: временный эксклюзивный доступ к данным внутри.

  • Это право представлено MutexGuard. Одновременно может существовать только один охранник для данного мьютекса. Пока он существует, он предоставляет доступ &mut T.

  • Хотя lock() принимает &self, он возвращает MutexGuard с изменяемым доступом. Это работает через внутреннюю изменяемость, при которой тип сам управляет правилами заимствования внутри, чтобы позволить изменение через &self.

  • MutexGuard реализует Deref и DerefMut, что делает доступ удобным. Вы блокируете мьютекс и используете охранника как &mut T.

  • Мьютекс освобождается через MutexGuard::drop(). Вы никогда не вызываете явную функцию разблокировки.

Drop-охранники

Охранник освобождения (drop guard) в Rust — это временный объект, который выполняет некоторую очистку, когда выходит из области видимости. В случае Mutex метод lock возвращает MutexGuard, который автоматически разблокирует мьютекс при drop:

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct Mutex {
    is_locked: bool,
}

struct MutexGuard<'a> {
    mutex: &'a mut Mutex,
}

impl Mutex {
    fn new() -> Self {
        Self { is_locked: false }
    }

    fn lock(&mut self) -> MutexGuard<'_> {
        self.is_locked = true;
        MutexGuard { mutex: self }
    }
}

impl Drop for MutexGuard<'_> {
    fn drop(&mut self) {
        self.mutex.is_locked = false;
    }
}
  • Пример выше показывает упрощённый Mutex и связанный с ним охранник.

  • Хотя это не промышленная реализация, она иллюстрирует основную идею:

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

Для дальнейшего изучения

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

Для краткости опущено несколько возможностей:

  • Реальный Mutex<T> хранит защищаемое значение внутри мьютекса. Этот упрощённый пример полностью опускает значение, чтобы сосредоточиться только на механизме охранника освобождения.
  • Удобный доступ через Deref и DerefMut у MutexGuard (позволяющий охраннику вести себя как &T или &mut T).
  • Полностью блокирующий метод .lock() и неблокирующий вариант try_lock.

Можно изучить реализацию Mutex в стандартной библиотеке Rust как пример промышленной реализации мьютекса. Ещё один полезный источник — Mutex из крейта parking_lot.

Drop bomb: обеспечение корректности API

Используйте Drop, чтобы обеспечивать инварианты и выявлять некорректное использование API. «Drop bomb» вызывает панику, если значение освобождается, не будучи явно завершённым.

Этот паттерн часто используется, когда операция завершения (например, commit() или rollback()) должна возвращать Result, что нельзя сделать из Drop.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::io::{self, Write};

struct Transaction {
    active: bool,
}

impl Transaction {
    fn start() -> Self {
        Self { active: true }
    }

    fn commit(mut self) -> io::Result<()> {
        writeln!(io::stdout(), "COMMIT")?;
        self.active = false;
        Ok(())
    }
}

impl Drop for Transaction {
    fn drop(&mut self) {
        if self.active {
            panic!("Transaction dropped without commit!");
        }
    }
}

fn main() -> io::Result<()> {
    let tx = Transaction::start();
    // Используйте `tx`, чтобы построить транзакцию, затем зафиксируйте её.
    // Закомментируйте вызов `commit`, чтобы увидеть панику.
    tx.commit()?;
    Ok(())
}
  • В некоторых системах значение должно быть завершено определённым API до того, как его освободят.

    Например, Transaction может потребоваться зафиксировать (commit) или откатить (rollback).

  • Drop bomb гарантирует, что значение вроде Transaction не может быть тихо освобождено в незавершённом состоянии. Деструктор вызывает панику, если транзакция не была явно завершена (например, через commit()).

  • Операция завершения (например, commit()) обычно принимает self по значению. Это гарантирует, что после завершения транзакции исходный объект уже нельзя использовать.

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

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

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

  • Паника в релизных сборках разумна, когда тихое неправильное использование привело бы к серьёзным проблемам корректности или безопасности.

  • Вопрос: зачем нам нужен флаг active внутри Transaction? Почему drop() не может паниковать безусловно?

    Ожидаемый ответ: commit() принимает self по значению и запускает drop(), который вызвал бы панику.

Для дальнейшего изучения

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

  • Крейт drop_bomb: небольшая утилита, которая вызывает панику при освобождении, если её явно не обезвредить с помощью .defuse(). Есть вариант DebugDropBomb, который активируется только в отладочных сборках.

Drop bomb: использование std::mem::forget

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::io::{self, Write};

struct Transaction;

impl Transaction {
    fn start() -> Self {
        Transaction
    }

    fn commit(self) -> io::Result<()> {
        writeln!(io::stdout(), "COMMIT")?;

        // Обезвреживаем drop bomb, не давая Drop когда-либо выполниться.
        std::mem::forget(self);

        Ok(())
    }
}

impl Drop for Transaction {
    fn drop(&mut self) {
        // Это и есть «drop bomb»
        panic!("Transaction dropped without commit!");
    }
}

fn main() -> io::Result<()> {
    let tx = Transaction::start();
    // Используйте `tx`, чтобы построить транзакцию, затем зафиксируйте её.
    // Закомментируйте вызов `commit`, чтобы увидеть панику.
    tx.commit()?;
    Ok(())
}

В этом примере убран флаг из предыдущего слайда, и метод drop вызывает панику безусловно. Чтобы избежать этой паники при успешном commit, метод commit теперь забирает владение транзакцией и вызывает std::mem::forget, что не даёт выполниться методу Drop::drop().

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

Мы можем обойтись без флага времени выполнения, тактически применяя mem::forget(). Когда транзакция успешно фиксируется, мы можем обезвредить drop bomb, вызвав std::mem::forget для значения, что не даст выполниться его реализации Drop.

Функции forget и drop

Ниже приведены сигнатуры функций drop() и forget():

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

// std::mem::forget
fn forget<T>(t: T) {
    let _ = std::mem::ManuallyDrop::new(t);
}

// std::mem::drop
fn drop<T>(_x: T) {}
  • И mem::forget(), и mem::drop() забирают владение значением t.

  • Несмотря на одинаковую сигнатуру функции, у них противоположные эффекты:

    • forget() использует ManuallyDrop, чтобы не дать вызвать деструктор Drop::drop().

      Это полезно для сценариев вроде реализации drop bomb или иного отказа от поведения деструктора.

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

    • drop() — удобная функция для освобождения значения. Поскольку t перемещается в функцию, он автоматически освобождается, что вызывает его реализацию Drop::drop() до возврата управления родительской функцией.

Охранники области видимости

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use scopeguard::{ScopeGuard, guard};
use std::fs::{self, File};
use std::io::Write;

fn download_successful() -> bool {
    // [...]
    true
}

fn main() {
    let path = "download.tmp";
    let mut file = File::create(path).expect("cannot create temporary file");

    // Настраиваем очистку сразу после создания файла
    let cleanup = guard(path, |path| {
        println!("download failed, deleting: {:?}", path);
        let _ = fs::remove_file(path);
    });

    writeln!(file, "partial data...").unwrap();

    if download_successful() {
        // Загрузка успешна, сохраняем файл
        let path = ScopeGuard::into_inner(cleanup);
        println!("Download '{path}' complete!");
    }
    // Иначе охранник срабатывает и удаляет файл
}
  • Этот пример моделирует процесс загрузки. Сначала мы создаём временный файл, затем используем охранник области видимости, чтобы гарантировать удаление файла, если загрузка не удалась.

  • Крейт scopeguard позволяет удобно определить одноразовую очистку на основе Drop, не определяя собственный тип с собственной реализацией Drop.

  • Охранник создаётся сразу после создания файла, поэтому даже если writeln!() завершится с ошибкой, файл всё равно будет очищен. Такой порядок необходим для корректности.

  • guard() создаёт экземпляр ScopeGuard. Он принимает значение, определённое пользователем (в данном случае path), и замыкание очистки, которое позже получит это значение.

  • Замыкание охранника выполняется при выходе из области видимости, если только оно не обезврежено через ScopeGuard::into_inner (извлечение значения, чтобы охранник ничего не делал при освобождении). На пути успеха мы вызываем into_inner, поэтому охранник не удалит файл.

  • Охранник области видимости похож на функцию defer в Go.

  • Этот паттерн идеален для сценариев «очистка при неудаче», где очистка должна выполняться по умолчанию, если только не выбран путь успеха явно.

  • Этот паттерн также полезен, когда вы не контролируете стратегию очистки объекта ресурса. В этом примере File::drop() закрывает файл, но не удаляет его.

  • Крейт scopeguard также поддерживает стратегии очистки через трейт Strategy. Можно выбрать, запускать ли охранник только при раскручивании или только при успехе, а не всегда.

Drop: Option

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct File(Option<Handle>);

impl File {
    fn open(path: &'static str) -> std::io::Result<Self> {
        Ok(Self(Some(Handle { path })))
    }

    fn write(&mut self, data: &str) -> std::io::Result<()> {
        // Нужно пройти через `Option`, чтобы получить `Handle`,
        // прежде чем мы сможем его использовать.
        let handle = self.0.as_ref().unwrap();
        println!("write '{data}' to file '{}'", handle.path);
        Ok(())
    }
}

impl Drop for File {
    fn drop(&mut self) {
        let handle = self.0.take().unwrap();
        handle.close();
    }
}

struct Handle {
    path: &'static str,
}

impl Handle {
    fn close(self) {
        println!("Closing {}", self.path);
    }
}

fn main() -> std::io::Result<()> {
    let mut file = File::open("foo.txt")?;
    file.write("hello")?;
    Ok(())
}
  • В этом примере мы хотим вызвать close для внутреннего Handle в нашей реализации Drop, но close требует владения Handle. Обычно это невозможно, потому что в drop мы не получаем владение объектом File, и поэтому не можем переместить значение из поля.

  • Обёртывание дескриптора в Option даёт нам способ переместить значение из поля через изменяемую ссылку.

  • Главный недостаток — эргономика. Option заставляет обрабатывать и вариант Some, и вариант None, даже в местах, где по логике None возникнуть не может. Система типов Rust не может выразить связь между File и его Handle, поэтому мы вынуждены обрабатывать оба случая вручную.

Для дальнейшего изучения

Вместо Option можно использовать ManuallyDrop, который подавляет автоматическое уничтожение: Rust не вызывает Drop для значения, поэтому очистку нужно выполнять самостоятельно.

Пример с scopeguard на предыдущем слайде показывает, как ManuallyDrop может заменить Option, чтобы не обрабатывать None там, где значение должно существовать всегда.

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

Трейты-расширения

Иногда бывает желательно расширить внешние типы новыми методами. Например, позволить коду проверять, является ли строка палиндромом, с помощью синтаксиса вызова метода: s.is_palindrome().

Может показаться естественным обратиться к блоку impl:

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

// 🛠️❌
impl str {
    pub fn is_palindrome(&self) -> bool {
        self.chars().eq(self.chars().rev())
    }
}

Однако компилятор Rust этого не допустит. Но обойти это ограничение можно с помощью паттерна «трейт-расширение».

This slide and its sub-slides should take about 65 minutes.
  • Элемент Rust (будь то трейт или тип) называют:

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

    Обратите внимание, как сообщение об ошибке компилятора подталкивает к паттерну трейта-расширения.

  • Объясните, что многие ограничения системы типов Rust направлены на предотвращение неоднозначности.

    Что произойдёт, если разрешить определять новые собственные методы для внешних типов? Разные крейты в дереве зависимостей могли бы в итоге определить разные методы с одним и тем же именем для одного и того же внешнего типа.

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

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

    Rust решил вовсе избежать этой проблемы, запретив определять новые собственные методы для внешних типов.

  • Другие языки (например, Kotlin, C#, Swift) позволяют добавлять методы к существующим типам, которые часто называют «методами расширения». Это приводит к другим компромиссам с точки зрения возможной неоднозначности и необходимости глобальных рассуждений.

Расширение чужих типов

Трейт-расширение — это локальное определение трейта, основная цель которого — добавить новые методы внешним типам.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

mod ext {
    pub trait StrExt {
        fn is_palindrome(&self) -> bool;
    }

    impl StrExt for str {
        fn is_palindrome(&self) -> bool {
            self.chars().eq(self.chars().rev())
        }
    }
}

fn main() {
    // Подключите трейт-расширение в область видимости...
    pub use ext::StrExt as _;
    // ...а затем вызывайте его методы так, как если бы это были собственные методы
    assert!("dad".is_palindrome());
    assert!(!"grandma".is_palindrome());
}
  • По соглашению к имени трейта-расширения добавляют суффикс Ext.

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

    Авторитетным источником по соглашениям об именовании считается RFC «Extension Trait».

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

  • Трейт-расширение должен быть в области видимости при вызове его методов.

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

  • В примере выше используется импорт с подчёркиванием (use ext::StringExt as _), чтобы свести к минимуму вероятность конфликта имён с другими импортированными трейтами.

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

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

Конфликты разрешения методов

Что происходит, когда возникает конфликт имён между собственным методом и методом-расширением?

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

mod ext {
    pub trait CountOnesExt {
        fn count_ones(&self) -> u32;
    }

    impl CountOnesExt for i32 {
        fn count_ones(&self) -> u32 {
            let value = *self;
            (0..32).filter(|i| ((value >> i) & 1i32) == 1).count() as u32
        }
    }
}
fn main() {
    pub use ext::CountOnesExt;
    // Какой метод `count_ones` будет вызван?
    // Тот, что из `CountOnesExt`? Или собственный метод `i32`?
    assert_eq!((-1i32).count_ones(), 32);
}
  • Внешний тип в новой версии может добавить новый собственный метод с тем же именем, что и наш метод-расширение.

    Спросите: что произойдёт в примере выше? Будет ли ошибка компиляции? Получит ли один из двух методов более высокий приоритет? Какой именно?

    Добавьте panic!("Extension trait"); в тело CountOnesExt::count_ones, чтобы понять, какой метод вызывается.

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

    Продемонстрируйте: измените вызов на (&-1i32).count_ones() и покажите, что теперь вызывается метод трейта. Затем измените метод трейта так, чтобы он принимал self (вместо &self), и покажите, что снова вызывается собственный метод.

  • Избегайте конфликтов имён между методами трейтов-расширений и собственными методами. Алгоритм разрешения методов в Rust сложен и может удивить пользователей вашего кода.

Для дальнейшего изучения

  • Взаимодействие приоритетного поиска, который использует алгоритм разрешения методов Rust, и автоматического Deref можно применить для эмуляции специализации на стабильном тулчейне, в первую очередь в контексте кода, сгенерированного макросами. Подробности см. в статье «Autoref Specialization».

Конфликты методов трейтов

Что происходит, когда возникает конфликт имён между двумя разными методами трейтов, реализованными для одного и того же типа?

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

mod ext {
    pub trait Ext1 {
        fn is_palindrome(&self) -> bool;
    }

    pub trait Ext2 {
        fn is_palindrome(&self) -> bool;
    }

    impl Ext1 for str {
        fn is_palindrome(&self) -> bool {
            self.chars().eq(self.chars().rev())
        }
    }

    impl Ext2 for str {
        fn is_palindrome(&self) -> bool {
            self.chars().eq(self.chars().rev())
        }
    }
}

pub use ext::{Ext1, Ext2};

// Какой метод будет вызван?
// Тот, что из `Ext1`? Или тот, что из `Ext2`?
fn main() {
    assert!("dad".is_palindrome());
}
  • Расширяемый вами трейт в новой версии может добавить новый метод трейта с тем же именем, что и ваш метод-расширение. Или другой трейт-расширение для того же типа может определить метод, имя которого конфликтует с вашим методом-расширением.

    Спросите: что произойдёт в примере выше? Будет ли ошибка компиляции? Получит ли один из двух методов более высокий приоритет? Какой именно?

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

    Чтобы устранить этот конфликт, нужно явно указать, какой трейт вы хотите использовать.

    Продемонстрируйте: вызовите Ext1::is_palindrome("dad") или Ext2::is_palindrome("dad") вместо "dad".is_palindrome().

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

  • Продемонстрируйте: замените "dad".is_palindrome() на <str as Ext1>::is_palindrome("dad") или <str as Ext2>::is_palindrome("dad").

Расширение других трейтов

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

mod ext {
    use std::fmt::Display;

    pub trait DisplayExt {
        fn quoted(&self) -> String;
    }

    impl<T: Display> DisplayExt for T {
        fn quoted(&self) -> String {
            format!("'{}'", self)
        }
    }
}

fn main() {
    use ext::DisplayExt as _;

    assert_eq!("dad".quoted(), "'dad'");
    assert_eq!(4.quoted(), "'4'");
    assert_eq!(true.quoted(), "'true'");
}
  • Обратите внимание, как мы добавили новое поведение сразу нескольким типам. .quoted() можно вызывать для строковых срезов, чисел и булевых значений, так как все они реализуют трейт Display.

    Эта разновидность паттерна трейта-расширения использует обобщённые реализации (blanket).

    Обобщённая реализация (blanket impl) реализует трейт для всех типов T, которые удовлетворяют ограничениям трейтов, указанным в блоке impl. В данном случае единственное требование — чтобы T реализовывал трейт Display.

  • Обратите внимание слушателей на реализацию DisplayExt::quoted: мы не можем делать никаких предположений о T, кроме того, что он реализует Display. Вся наша логика должна либо использовать методы из Display, либо функции и макросы, которые не требуют других трейтов.

    Например, мы можем вызвать format! с T, но не можем вызвать .to_uppercase(), потому что T не обязательно является String.

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

  • По соглашению трейт-расширение называют по имени расширяемого трейта с суффиксом Ext. В примере выше это DisplayExt.

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

  • Существуют целые крейты, которые расширяют трейты стандартной библиотеки новой функциональностью.

    • Крейт itertools предоставляет трейт Itertools, который расширяет Iterator. Он добавляет множество адаптеров итераторов, например interleave и unique. Он предоставляет новые алгоритмические строительные блоки для конвейеров итераторов, построенных с помощью цепочек вызовов методов.

    • Крейт futures предоставляет трейт FutureExt, который расширяет трейт Future новыми комбинаторами и вспомогательными методами.

Для дальнейшего изучения

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

    Стабильные методы являются частью определения трейта.

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

  • Трейты-расширения можно использовать, чтобы разбить трейт, несовместимый с dyn, на две части:

    • Совместимое с dyn ядро, ограниченное методами, которые удовлетворяют требованиям совместимости с dyn.
    • Трейт-расширение, содержащий оставшиеся методы, которые несовместимы с dyn (например, методы с обобщённым параметром).
  • Конкретные типы, реализующие основной трейт, смогут вызывать все методы благодаря обобщённой реализации для трейта-расширения. Объекты трейтов (dyn CoreTrait) смогут вызывать все методы основного трейта, а также те методы трейта-расширения, которые не требуют Self: Sized.

Стоит ли определять трейт-расширение?

В каких сценариях следует предпочесть трейт-расширение свободной функции?

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub trait StrExt {
    fn is_palindrome(&self) -> bool;
}

impl StrExt for &str {
    fn is_palindrome(&self) -> bool {
        self.chars().eq(self.chars().rev())
    }
}

// против

fn is_palindrome(s: &str) -> bool {
    s.chars().eq(s.chars().rev())
}

Главное преимущество трейтов-расширений — простота обнаружения.

  • Обнаруживаемость: методы-расширения легче найти, чем свободные функции. Языковые серверы (например, rust-analyzer) предложат их, если вы введёте . после экземпляра внешнего типа.

  • Цепочки вызовов: важное эргономическое преимущество трейтов-расширений — цепочки вызовов методов. Именно на этом основан трейт Iterator, который позволяет писать плавные вызовы вроде data.iter().filter(...).map(...). Добиться того же с помощью свободных функций было бы гораздо неудобнее (map(filter(iter(data), ...), ...)).

  • Обобщения и dyn: трейт можно использовать как ограничение трейта в обобщённом коде или как часть dyn Trait, тогда как свободная функция не всегда может использоваться в обобщённом контексте.

  • Связность API: трейты-расширения помогают создать связный API. Если у вас есть несколько связанных функций для внешнего типа (например, is_palindrome, word_count, to_kebab_case), объединить их в один трейт StrExt часто удобнее, чем предоставлять пользователю несколько свободных функций для импорта.

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

Паттерн typestate: проблема

Как гарантировать, что над значением допускаются только операции, допустимые для его текущего состояния?

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::fmt::Write as _;

#[derive(Default)]
struct Serializer {
    output: String,
}

impl Serializer {
    fn serialize_struct_start(&mut self, name: &str) {
        let _ = writeln!(&mut self.output, "{name} {{");
    }

    fn serialize_struct_field(&mut self, key: &str, value: &str) {
        let _ = writeln!(&mut self.output, "  {key}={value};");
    }

    fn serialize_struct_end(&mut self) {
        self.output.push_str("}\n");
    }

    fn finish(self) -> String {
        self.output
    }
}

fn main() {
    let mut serializer = Serializer::default();
    serializer.serialize_struct_start("User");
    serializer.serialize_struct_field("id", "42");
    serializer.serialize_struct_field("name", "Алиса");

    // serializer.serialize_struct_end(); // ← Упс! Забыли

    println!("{}", serializer.finish());
}
This slide and its sub-slides should take about 65 minutes.
  • Этот Serializer предназначен для записи структурированного значения.

  • Однако в этом примере мы забыли вызвать serialize_struct_end() перед finish(). В результате сериализованный вывод оказывается неполным или синтаксически некорректным.

  • Один из способов исправить это — вручную отслеживать внутреннее состояние и возвращать Result из методов вроде serialize_struct_field() или finish(), если текущее состояние недопустимо.

  • Однако у этого подхода есть недостатки:

    • При реализации легко допустить ошибку. Система типов Rust не может помочь обеспечить корректность переходов между состояниями.

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

  • Более удачное решение — смоделировать допустимые переходы между состояниями прямо в системе типов.

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

Пример паттерна typestate

Паттерн typestate: пример

Паттерн typestate кодирует часть состояния значения во время выполнения в его тип. Это позволяет предотвращать недопустимые или неприменимые операции на этапе компиляции.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::fmt::Write as _;

#[derive(Default)]
struct Serializer {
    output: String,
}

struct SerializeStruct {
    serializer: Serializer,
}

impl Serializer {
    fn serialize_struct(mut self, name: &str) -> SerializeStruct {
        writeln!(&mut self.output, "{name} {{").unwrap();
        SerializeStruct { serializer: self }
    }

    fn finish(self) -> String {
        self.output
    }
}

impl SerializeStruct {
    fn serialize_field(mut self, key: &str, value: &str) -> Self {
        writeln!(&mut self.serializer.output, "  {key}={value};").unwrap();
        self
    }

    fn finish_struct(mut self) -> Serializer {
        self.serializer.output.push_str("}\n");
        self.serializer
    }
}

fn main() {
    let serializer = Serializer::default()
        .serialize_struct("User")
        .serialize_field("id", "42")
        .serialize_field("name", "Алиса")
        .finish_struct();

    println!("{}", serializer.finish());
}

Схема использования Serializer:

SerializerSerializeStructserializestructserializefieldfinishstructfinish
  • Этот пример вдохновлён трейтом Serializer из Serde. Serde использует typestate внутри себя, чтобы сериализация следовала допустимой структуре. Подробнее см.: https://serde.rs/impl-serializer.html

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

  • В этом примере:

    • Начинаем с Serializer, который позволяет только начать сериализацию структуры.

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

    • Исходный Serializer больше недоступен — это не позволяет смешивать режимы (например, начать другую структуру внутри структуры) или вызвать finish() слишком рано.

    • Только после вызова .finish_struct() мы снова получаем Serializer. На этом этапе вывод можно финализировать или переиспользовать.

  • Если мы забудем вызвать finish_struct() и отбросим SerializeStruct раньше времени, Serializer тоже будет освобождён. Это гарантирует, что неполный вывод не попадёт в систему.

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

За пределами простого typestate

За пределами простого typestate

Как управлять всё более сложными потоками конфигурации с множеством возможных состояний и переходов, по-прежнему предотвращая несовместимые операции?

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct Serializer {/* [...] */}
struct SerializeStruct {/* [...] */}
struct SerializeStructProperty {/* [...] */}
struct SerializeList {/* [...] */}

impl Serializer {
    // TODO, реализовать:
    //
    // fn serialize_struct(self, name: &str) -> SerializeStruct
    // fn finish(self) -> String
}

impl SerializeStruct {
    // TODO, реализовать:
    //
    // fn serialize_property(mut self, name: &str) -> SerializeStructProperty

    // TODO,
    // Как завершить эту структуру? Это зависит от того, где она находится:
    // - На корневом уровне: вернуть `Serializer`
    // - Как свойство внутри другой структуры: вернуть `SerializeStruct`
    // - Как значение внутри списка: вернуть `SerializeList`
    //
    // fn finish(self) -> ???
}

impl SerializeStructProperty {
    // TODO, реализовать:
    //
    // fn serialize_string(self, value: &str) -> SerializeStruct
    // fn serialize_struct(self, name: &str) -> SerializeStruct
    // fn serialize_list(self) -> SerializeList
    // fn finish(self) -> SerializeStruct
}

impl SerializeList {
    // TODO, реализовать:
    //
    // fn serialize_string(mut self, value: &str) -> Self
    // fn serialize_struct(mut self, value: &str) -> SerializeStruct
    // fn serialize_list(mut self) -> SerializeList

    // TODO:
    // Как и в случае с `SerializeStruct::finish`, тип возвращаемого значения зависит от вложенности.
    //
    // fn finish(mut self) -> ???
}

Схема допустимых переходов:

structureserializerpropertylistString
  • Развивая предыдущий сериализатор, мы теперь хотим поддержать вложенные структуры и списки.

  • Однако это приводит одновременно к дублированию и структурной сложности.

  • Что ещё важнее, мы упираемся в ограничение системы типов: мы не можем аккуратно выразить, что должен возвращать finish(), не дублируя варианты для каждого контекста вложенности (например, корень, структура, список).

  • По схеме допустимых переходов можно заметить:

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

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

Паттерн typestate с обобщениями

Паттерн typestate с обобщениями

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct Serializer<S> {
    // [...]
    indent: usize,
    buffer: String,
    state: S,
}

struct Root;
struct Struct<S>(S);
struct Property<S>(S);
struct List<S>(S);

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

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

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

  • Методы, общие для всех состояний, можно определить для любого S в Serializer<S>.

  • Маркерные типы (например, List<S>) не создают накладных расходов ни по памяти, ни во время выполнения, так как не содержат данных, кроме возможного типа нулевого размера (Zero-Sized Type). Их единственная роль — обеспечивать корректное использование API с помощью системы типов.

Сериализатор: реализация Root

Сериализатор: реализация Root

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::fmt::Write as _;
struct Serializer<S> {
    // [...]
    indent: usize,
    buffer: String,
    state: S,
}

struct Root;
struct Struct<S>(S);

impl Serializer<Root> {
    fn new() -> Self {
        // [...]
        Self { indent: 0, buffer: String::new(), state: Root }
    }

    fn serialize_struct(mut self, name: &str) -> Serializer<Struct<Root>> {
        // [...]
        writeln!(self.buffer, "{name} {{").unwrap();
        Serializer {
            indent: self.indent + 1,
            buffer: self.buffer,
            state: Struct(self.state),
        }
    }

    fn finish(self) -> String {
        // [...]
        self.buffer
    }
}

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

Serializer<Root>Serializer<Struct<Root>>Stringserializestructfinishstructfinish
  • На «корневом» уровне нашего Serializer единственной допустимой конструкцией является Struct.

  • Serializer можно финализировать в String только с этого корневого уровня.

Сериализатор: реализация Struct

Сериализатор: реализация Struct

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::fmt::Write as _;
struct Serializer<S> {
    // [...]
    indent: usize,
    buffer: String,
    state: S,
}

struct Struct<S>(S);
struct Property<S>(S);

impl<S> Serializer<Struct<S>> {
    fn serialize_property(mut self, name: &str) -> Serializer<Property<Struct<S>>> {
        // [...]
        write!(self.buffer, "{}{name}: ", " ".repeat(self.indent * 2)).unwrap();
        Serializer {
            indent: self.indent,
            buffer: self.buffer,
            state: Property(self.state),
        }
    }

    fn finish_struct(mut self) -> Serializer<S> {
        // [...]
        self.indent -= 1;
        writeln!(self.buffer, "{}}}", " ".repeat(self.indent * 2)).unwrap();
        Serializer { indent: self.indent, buffer: self.buffer, state: self.state.0 }
    }
}

Теперь схему можно расширить следующим образом:

Serializer<Root>Serializer<Struct<S>>Serializer<Property<Struct<S>>>Stringfinishstructserializestructfinishstructserializepropertyfinish
  • Struct может содержать только Property;

  • Завершение Struct возвращает управление родителю. На предыдущем слайде предполагалось, что родитель — это Root, но на самом деле им может быть и что-то другое, например Struct в случае вложенных «структур».

Сериализатор: реализация Property

Сериализатор: реализация Property

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::fmt::Write as _;
struct Serializer<S> {
    // [...]
    indent: usize,
    buffer: String,
    state: S,
}

struct Struct<S>(S);
struct Property<S>(S);
struct List<S>(S);

impl<S> Serializer<Property<Struct<S>>> {
    fn serialize_struct(mut self, name: &str) -> Serializer<Struct<Struct<S>>> {
        // [...]
        writeln!(self.buffer, "{name} {{").unwrap();
        Serializer {
            indent: self.indent + 1,
            buffer: self.buffer,
            state: Struct(self.state.0),
        }
    }

    fn serialize_list(mut self) -> Serializer<List<Struct<S>>> {
        // [...]
        writeln!(self.buffer, "[").unwrap();
        Serializer {
            indent: self.indent + 1,
            buffer: self.buffer,
            state: List(self.state.0),
        }
    }

    fn serialize_string(mut self, value: &str) -> Serializer<Struct<S>> {
        // [...]
        writeln!(self.buffer, "{value},").unwrap();
        Serializer { indent: self.indent, buffer: self.buffer, state: self.state.0 }
    }
}

С добавлением методов состояния Property наша схема почти завершена:

Serializer<Root>Serializer<Struct<S>>Serializer<Property<S>>StringSerializer<List<S>>finishstructserializestructfinishstructserializestringorstructserializepropertyfinishserializelist
  • Свойство может быть определено как String, Struct<S> или List<S>, что позволяет представлять вложенные структуры.

  • На этом пошаговая реализация завершена. Полная реализация, включая поддержку List<S>, показана на следующем слайде.

Сериализатор: полная реализация

Serializer: полная реализация

Возвращаясь к исходному желаемому потоку:

structureserializerpropertylistString

Теперь это напрямую отражено в типах нашего сериализатора:

Serializer<Root>Serializer<Struct<S>>Serializer<Property<S>>StringSerializer<List<S>>finishstructserializestructfinishlistfinishstructserializestringorstructserializepropertyfinishfinishstructserializelistserializelistorstringorfinishlist

Код полной реализации Serializer и всех его состояний можно найти в этой песочнице Rust.

  • Этот паттерн не является серебряной пулей. Он всё равно допускает такие проблемы, как:

    • Пустые или некорректные имена свойств (что можно исправить с помощью паттерна newtype)
    • Повторяющиеся имена свойств (их можно отслеживать в Struct<S> и обрабатывать через Result)
  • Если возникают ошибки валидации, можно также изменить сигнатуры методов так, чтобы они возвращали Result, что позволит восстанавливаться после ошибок:

    #![allow(unused)]
    fn main() {
    // Copyright 2025 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    struct PropertySerializeError<S> {
        kind: PropertyError,
        serializer: Serializer<Struct<S>>,
    }
    
    impl<S> Serializer<Struct<S>> {
        fn serialize_property(
            self,
            name: &str,
        ) -> Result<Serializer<Property<Struct<S>>>, PropertySerializeError<S>> {
            /* ... */
        }
    }
    }
  • Этот API мощный, но не всегда удобный. Промышленные сериализаторы обычно отдают предпочтение более простым API и оставляют паттерн typestate для обеспечения критически важных инвариантов.

  • Один отличный пример из практики — rustls::ClientConfig, который использует typestate вместе с обобщениями, чтобы провести пользователя через безопасные и корректные шаги настройки.

Использование анализатора заимствований для обеспечения инвариантов

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Двери можно открывать и закрывать, и для запирания или отпирания нужен правильный ключ.
/// Смоделировано с помощью неизменяемой ссылки на ключ и владения дверью.
pub struct DoorKey {
    pub key_shape: u32,
}
pub struct LockedDoor {
    lock_shape: u32,
}
pub struct OpenDoor {
    lock_shape: u32,
}

fn open_door(key: &DoorKey, door: LockedDoor) -> Result<OpenDoor, LockedDoor> {
    if door.lock_shape == key.key_shape {
        Ok(OpenDoor { lock_shape: door.lock_shape })
    } else {
        Err(door)
    }
}

fn close_door(key: &DoorKey, door: OpenDoor) -> Result<LockedDoor, OpenDoor> {
    if door.lock_shape == key.key_shape {
        Ok(LockedDoor { lock_shape: door.lock_shape })
    } else {
        Err(door)
    }
}

fn main() {
    let key = DoorKey { key_shape: 7 };
    let closed_door = LockedDoor { lock_shape: 7 };
    let opened_door = open_door(&key, closed_door);
    if let Ok(opened_door) = opened_door {
        println!("Opened the door with key shape '{}'", key.key_shape);
    } else {
        eprintln!(
            "Door wasn't opened! Your key only opens locks with shape '{}'",
            key.key_shape
        );
    }
}
This slide and its sub-slides should take about 90 minutes.
  • Мы видели, как анализатор заимствований предотвращает ошибки безопасности памяти (использование после освобождения, гонки данных).

  • Мы также уже использовали типы для формирования и ограничения API с помощью паттерна Typestate.

  • Возможности языка часто вводятся с конкретной целью.

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

    Java 5 ввела обобщения в 2004 году с основной заявленной целью — обеспечить типобезопасные коллекции.

    Сначала внедрение шло медленно, но некоторые новые проекты начали проектировать свои API вокруг обобщений с самого начала.

    С тех пор пользователи и разработчики языка расширили использование обобщений на другие области проектирования типобезопасных API:

    • Информацию о классе можно сохранить с помощью Class<T> в Java или TypeToken<T> из Guava.
    • Паттерн Builder можно реализовать с помощью рекурсивных обобщений. Здесь мы хотим сделать нечто похожее: хотя анализатор заимствований был введён для предотвращения использования после освобождения и гонок данных, мы будем рассматривать его как ещё один инструмент проектирования API. Его можно использовать для моделирования свойств программы, которые не имеют отношения к предотвращению ошибок безопасности памяти.
  • Чтобы использовать анализатор заимствований как инструмент решения задач, нужно «забыть», что его изначальная цель — предотвращать изменяемые псевдонимы в контексте использования после освобождения и гонок данных.

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

  • В этом примере владение и заимствование используются для моделирования состояния физической двери.

    open_door потребляет LockedDoor и возвращает новое значение OpenDoor. Прежнее значение LockedDoor становится недоступно.

    Если используется неправильный ключ, дверь остаётся запертой. Она возвращается как вариант Err у Result.

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

  • Аналогично, lock_door потребляет OpenDoor, поэтому закрыть дверь дважды невозможно — это проверяется на этапе компиляции.

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

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

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

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

// Внутренний тип данных, чтобы было за что держаться.
pub struct Internal;
// Внешние данные.
pub struct Data(Internal);

fn shared_use(value: &Data) -> &Internal {
    &value.0
}
fn exclusive_use(value: &mut Data) -> &mut Internal {
    &mut value.0
}
fn deny_future_use(value: Data) {}

fn demo_exclusive() {
    let mut value = Data(Internal);
    let shared = shared_use(&value);
    // let exclusive = exclusive_use(&mut value); // ❌🔨
    let shared_again = shared;
}

fn demo_denied() {
    let value = Data(Internal);
    deny_future_use(value);
    // let shared = shared_use(&value); // ❌🔨
}

fn main() {}
  • В этом примере правила анализатора заимствований переосмысляются: мы отходим от ссылок и переходим к смыслу в контекстах, не связанных с безопасностью памяти.

    Ничего не изменяется, ничего не передаётся между потоками.

  • В анализаторе заимствований Rust есть три разных способа «взять» значение:

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

    • Неизменяемая ссылка &T. Допускает псевдонимы, но запрещает изменяемый доступ, пока используются неизменяемые ссылки.

    • Изменяемая ссылка &mut T. В каждый момент времени для значения может существовать только одна такая ссылка, но из неё можно создавать неизменяемые ссылки.

  • Спросите: почему две закомментированные строки в функциях demo вызвали бы ошибки компиляции?

    demo_exclusive: потому что значение shared всё ещё имеет псевдоним после того, как взята ссылка exclusive.

    demo_denied: потому что value потребляется в строке перед тем, как из &value берётся ссылка shared_again_again.

  • Помните, что у каждой &T и &mut T есть время жизни. Просто в большинстве случаев пользователю не нужно ни указывать его, ни думать о нём.

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

Одноразовые значения

Иногда нужны значения, которые можно использовать только один раз. Один из важнейших примеров — криптография: «nonce» (одноразовое число).

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub struct Key(/* детали опущены */);

/// Одноразовое число, пригодное для криптографических целей.
pub struct Nonce(u32);

/// Криптографически стойкая функция генерации случайных чисел.
pub fn new_nonce() -> Nonce {
    Nonce(4) // выбрано честным броском кубика, https://xkcd.com/221/
}

/// Потребляет nonce, но не ключ и не данные.
pub fn encrypt(nonce: Nonce, key: &Key, data: &[u8]) {}

fn main() {
    let nonce = new_nonce();
    let data_1: [u8; 4] = [1, 2, 3, 4];
    let data_2: [u8; 4] = [4, 3, 2, 1];
    let key = Key(/* детали опущены */);

    // Ключ и данные можно повторно использовать, копировать и т. д., а nonce — нельзя.
    encrypt(nonce, &key, &data_1);
    // encrypt(nonce, &key, &data_2); // 🛠️❌
}
  • Проблема: как гарантировать, что значение будет использовано только один раз?

  • Мотивация: nonce — это случайные уникальные данные, которые используются в криптографических протоколах для защиты от атак повторного воспроизведения.

    Контекст: на практике люди случайно повторно использовали nonce. Чаще всего это приводит к полному краху криптографического протокола, и он перестаёт выполнять свою функцию.

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

  • У Rust есть очевидный инструмент для обеспечения инварианта «после использования повторно воспользоваться нельзя»: передача значения как аргумента, переданного по значению.

  • Обратите внимание: функция encrypt принимает nonce по значению, а key и data — по ссылке.

  • Техника для значений одноразового использования такова:

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

    • Не реализуйте трейты Clone/Copy или эквивалентные методы, чтобы пользователь не мог дублировать данные, которые мы хотим сохранить уникальными.

    • Сделайте внутренний тип непрозрачным (как в паттерне newtype), чтобы пользователь не мог самостоятельно изменить существующее значение.

  • Спросите: чего не хватает в коде слайда для полноценного паттерна newtype?

    Ожидаемый ответ: граница модуля.

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

    Исправление: поместите Key, Nonce и new_nonce внутрь модуля.

Для дальнейшего изучения

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

Взаимоисключающие ссылки / «Алиасинг XOR изменяемость»

Мы можем использовать взаимное исключение ссылок &T и &mut T, чтобы не допустить использования данных до того, как они будут готовы.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub struct QueryResult;
pub struct DatabaseConnection {/* поля опущены */}

impl DatabaseConnection {
    pub fn new() -> Self {
        Self {}
    }
    pub fn results(&self) -> &[QueryResult] {
        &[] // фиктивные результаты
    }
}

pub struct Transaction<'a> {
    connection: &'a mut DatabaseConnection,
}

impl<'a> Transaction<'a> {
    pub fn new(connection: &'a mut DatabaseConnection) -> Self {
        Self { connection }
    }
    pub fn query(&mut self, _query: &str) {
        // Отправляем запрос, но не ждём результатов.
    }
    pub fn commit(self) {
        // Завершаем выполнение транзакции и получаем результаты.
    }
}

fn main() {
    let mut db = DatabaseConnection::new();

    // Транзакция `tx` изменяемо заимствует `db`.
    let mut tx = Transaction::new(&mut db);
    tx.query("SELECT * FROM users");

    // Это не скомпилируется, потому что `db` уже изменяемо заимствован транзакцией `tx`.
    // let results = db.results(); // ❌🔨

    // Заимствование `db` заканчивается, когда `tx` потребляется методом `commit()`.
    tx.commit();

    // Теперь можно снова заимствовать `db`.
    let results = db.results();
}
  • Мотивация: в этом API базы данных запросы запускаются на асинхронное выполнение, а результаты становятся доступны только после завершения всей транзакции.

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

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

    Спросите: кто-нибудь неправильно понимал API, не читая документацию по правильному использованию?

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

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

  • Этот пример показывает, как можно использовать Aliasing XOR Mutability, чтобы предотвратить такое неправильное использование.

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

  • Конструктор типа Transaction принимает изменяемую ссылку на подключение к базе данных и сохраняет её в возвращаемом значении Transaction.

    Явное время жизни здесь не должно пугать: в данном случае оно означает лишь, что DatabaseConnection, переданное в конструктор, живёт дольше Transaction.

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

  • Пока существует Transaction, мы не можем обращаться к переменной DatabaseConnection, из которой он был создан.

    Продемонстрируйте: раскомментируйте строку db.results(). Это приведёт к ошибке компиляции, так как db уже изменяемо заимствован.

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

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

PhantomData 1/4: устранение дублирования одних и тех же данных и семантики

Паттерн newtype иногда вступает в противоречие с принципом DRY. Как это решить?

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub struct UserId(u64);
impl ChatUser for UserId { /* ... */ }

pub struct PatronId(u64);
impl ChatUser for PatronId { /* ... */ }

pub struct ModeratorId(u64);
impl ChatUser for ModeratorId { /* ... */ }
impl ChatModerator for ModeratorId { /* ... */ }

pub struct AdminId(u64);
impl ChatUser for AdminId { /* ... */ }
impl ChatModerator for AdminId { /* ... */ }
impl ChatAdmin for AdminId { /* ... */ }

// И так далее...
fn main() {}
  • Проблема: мы хотим использовать паттерн newtype для различения прав доступа, но приходится снова и снова реализовывать одни и те же трейты для одних и тех же данных.

  • Спросите: допустим, детали каждой реализации здесь одинаковы для разных типов. Какими способами можно не повторяться?

    Ожидаемые ответы:

    • Сделайте это перечислением, а не отдельными типами данных.
    • Объедините ID пользователя с токенами разрешений, например struct Admin(u64, UserPermission, ModeratorPermission, AdminPermission);
    • Добавить параметр типа, который кодирует права доступа.
    • Упомянуть PhantomData раньше времени (он есть в заголовке).

PhantomData 2/4: тегирование на уровне типов

Решим проблему с предыдущего слайда, добавив параметр типа.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

// use std::marker::PhantomData;

pub struct ChatId<T> { id: u64, tag: T }

pub struct UserTag;
pub struct AdminTag;

pub trait ChatUser {/* ... */}
pub trait ChatAdmin {/* ... */}

impl ChatUser for UserTag {/* ... */}
impl ChatUser for AdminTag {/* ... */} // Администраторы — это пользователи
impl ChatAdmin for AdminTag {/* ... */}

// impl <T> Debug for UserTag<T> {/* ... */}
// impl <T> PartialEq for UserTag<T> {/* ... */}
// impl <T> Eq for UserTag<T> {/* ... */}
// И так далее...

impl <T: ChatUser> ChatId<T> {/* Весь функционал для пользователей и выше */}
impl <T: ChatAdmin> ChatId<T> {/* Весь функционал только для администраторов */}

fn main() {}
  • Здесь мы используем параметр типа и ограничиваем права через типы-«метки», которые реализуют разные трейты разрешений.

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

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

    Ответ: если это не тип нулевого размера (например, () или struct MyTag;), мы выделяем больше памяти, чем нужно, тогда как нас интересует только информация о типах, существующая во время компиляции.

  • Продемонстрируйте: полностью удалите значение tag и скомпилируйте!

    Это не скомпилируется, так как есть неиспользуемый (фантомный) параметр типа.

    Здесь на помощь приходит PhantomData!

  • Продемонстрируйте: раскомментируйте импорт PhantomData и сделайте ChatId<T> таким:

    #![allow(unused)]
    fn main() {
    // Copyright 2025 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    pub struct ChatId<T> {
        id: u64,
        tag: PhantomData<T>,
    }
    }
  • PhantomData<T> — это тип нулевого размера с параметром типа. Его значения можно создавать, как и другие ZST: let phantom: PhantomData<UserTag> = PhantomData; или через реализацию PhantomData::default().

    Продемонстрируйте: реализуйте From<u64> для ChatId<T>, обратив внимание на создание PhantomData.

    #![allow(unused)]
    fn main() {
    // Copyright 2025 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    impl<T> From<u64> for ChatId<T> {
        fn from(value: u64) -> Self {
            ChatId {
                id: value,
                // Или `PhantomData::default()`
                tag: PhantomData,
            }
        }
    }
    }
  • PhantomData можно использовать как часть паттерна typestate, чтобы у данных с одинаковой структурой были разные методы: например, чтобы TaggedData<Start> реализовывал методы или трейты, которых нет у TaggedData<End>.

PhantomData 3/4: времена жизни для внешних ресурсов

Инварианты внешних ресурсов часто совпадают с тем, что можно выразить правилами времён жизни.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

// use std::marker::PhantomData;

/// Прямой FFI к библиотеке базы данных на C.
/// Мы получили этот API как есть и повлиять на него не можем.
mod ffi {
    pub type DatabaseHandle = u8; // Максимум 255 баз данных, открытых одновременно

    fn database_open(name: *const std::os::raw::c_char) -> DatabaseHandle {
        unimplemented!()
    }
    // ... и т. д.
}

struct DatabaseConnection(ffi::DatabaseHandle);

struct Transaction<'a>(&'a mut DatabaseConnection);

impl DatabaseConnection {
    fn new_transaction(&mut self) -> Transaction<'_> {
        Transaction(self)
    }
}

fn main() {}
  • Вспомните API транзакций из примера Aliasing XOR Mutability.

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

    В этом примере мы хотим реализовать API Transaction поверх внешнего API, который написан не на Rust.

    Начнём с определения типа Transaction, который хранит &mut DatabaseConnection.

  • Спросите: каковы ограничения этой реализации? Считайте, что u8 реализован корректно и что у нас достаточно информации, чтобы использовать внешний API.

    Ожидаемые ответы:

    • Косвенность занимает на 7 байт больше, чем нужно, на 64-битной платформе, а также требует разыменования указателя во время выполнения.
  • Проблема: мы хотим, чтобы транзакция заимствовала подключение к базе данных, которое её создало, но не хотим, чтобы объект Transaction хранил настоящую ссылку.

  • Спросите: что произойдёт, если убрать изменяемую ссылку из Transaction, но оставить параметр времени жизни?

    Ожидаемый ответ: неиспользуемый параметр времени жизни!

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

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

  • Продемонстрируйте: измените Transaction так:

    #![allow(unused)]
    fn main() {
    // Copyright 2025 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    struct Transaction<'a> {
        connection: DatabaseConnection,
        _phantom: PhantomData<&'a mut DatabaseConnection>,
    }
    }

    Обновите метод DatabaseConnection::new_transaction():

    #![allow(unused)]
    fn main() {
    // Copyright 2025 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    impl DatabaseConnection {
        fn new_transaction<'a>(&'a mut self) -> Transaction<'a> {
            Transaction { connection: DatabaseConnection(self.0), _phantom: PhantomData }
        }
    }
    }

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

    Поскольку PhantomData — это тип нулевого размера (как () или struct MyZeroSizedType;), размер Transaction теперь такой же, как у u8.

    Реализация, которая вместо этого хранила ссылку, была размером с usize.

Для дальнейшего изучения

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

  • В статье GhostCell (2021) и в её реализации показана именно такая работа. Хотя анализатор заимствований строг, всё же есть способы воспользоваться «запасными выходами» и затем показать, что способы их использования непротиворечивы и безопасны.

PhantomData 4/4: OwnedFd и BorrowedFd

BorrowedFd — наглядный пример использования PhantomData.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::marker::PhantomData;
use std::os::raw::c_int;

mod libc_ffi {
    use std::os::raw::{c_char, c_int};
    pub unsafe fn open(path: *const c_char, oflag: c_int) -> c_int {
        3
    }
    pub unsafe fn close(fd: c_int) {}
}

struct OwnedFd {
    fd: c_int,
}

impl OwnedFd {
    fn try_from_fd(fd: c_int) -> Option<Self> {
        if fd < 0 {
            return None;
        }
        Some(OwnedFd { fd })
    }

    fn as_fd<'a>(&'a self) -> BorrowedFd<'a> {
        BorrowedFd { fd: self.fd, _phantom: PhantomData }
    }
}

impl Drop for OwnedFd {
    fn drop(&mut self) {
        unsafe { libc_ffi::close(self.fd) };
    }
}

struct BorrowedFd<'a> {
    fd: c_int,
    _phantom: PhantomData<&'a ()>,
}

fn main() {
    // Создаём файл низкоуровневым системным вызовом с правами только на запись и создание.
    let fd = unsafe { libc_ffi::open(c"c_str.txt".as_ptr(), 065) };
    // Передаём владение целочисленным файловым дескриптором в `OwnedFd`.
    // `OwnedFd::drop()` закрывает файловый дескриптор.
    let owned_fd =
        OwnedFd::try_from_fd(fd).expect("Could not open file with syscall!");

    // Создаём `BorrowedFd` из `OwnedFd`.
    // `BorrowedFd::drop()` не закрывает файл, потому что не владеет им!
    let borrowed_fd: BorrowedFd<'_> = owned_fd.as_fd();
    // std::mem::drop(owned_fd); // ❌🔨
    std::mem::drop(borrowed_fd);
    let second_borrowed = owned_fd.as_fd();
    // owned_fd будет освобождён здесь, и файл будет закрыт.
}
  • Файловый дескриптор представляет доступ конкретного процесса к файлу.

    Напоминание: устройства и специфичные для ОС функции в Unix-подобных системах представляются как файлы.

  • OwnedFd — это обёрточный тип с владением для файлового дескриптора. Он владеет файловым дескриптором и закрывает его при освобождении.

    Примечание: здесь у нас собственная реализация, обратите внимание на явную реализацию Drop.

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

    Примечание: для BorrowedFd мы явно не реализуем Drop.

  • BorrowedFd использует время жизни, захваченное с помощью PhantomData, чтобы обеспечить инвариант: «если этот файловый дескриптор существует, файловый дескриптор ОС всё ещё открыт, хотя он не отвечает за его закрытие».

    Параметр времени жизни BorrowedFd требует, чтобы в программе существовало другое значение, которое живёт не меньше, чем этот конкретный BorrowedFd, или дольше него (в данном случае OwnedFd).

    Продемонстрируйте: раскомментируйте строку std::mem::drop(owned_fd) и попробуйте скомпилировать, чтобы показать, что borrowed_fd опирается на время жизни owned_fd.

    Проектировщики API закодировали это так, что именно это другое значение поддерживает доступ к файлу открытым.

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

Типы-токены

Типы с приватными конструкторами можно использовать как доказательство выполнения инвариантов.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub mod token {
    // Публичный тип с приватными полями за границей модуля.
    pub struct Token { proof: () }

    pub fn get_token() -> Option<Token> {
        Some(Token { proof: () })
    }
}

pub fn protected_work(token: token::Token) {
    println!("We have a token, so we can make assumptions.")
}

fn main() {
    if let Some(token) = token::get_token() {
        // У нас есть токен, поэтому мы можем выполнить эту работу.
        protected_work(token);
    } else {
        // Мы не смогли получить токен, поэтому не можем вызвать `protected_work`.
    }
}
This slide and its sub-slides should take about 95 minutes.
  • Мотивация: мы хотим ограничить доступ пользователя к функциональности, пока он не выполнит определённое действие.

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

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

  • Спросите: зачем здесь нужно поле proof: ()?

    Без proof: () у Token не было бы приватных полей, и пользователи могли бы создавать значения Token как угодно.

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

  • Поскольку тип Token находится за границей модуля (token), пользователи вне этого модуля не могут создать значение самостоятельно: у них нет доступа к полю proof.

    Разработчик API определяет методы и функции, которые создают эти токены. Пользователь — нет.

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

  • Спросите: как разработчик API может случайно создать способы обойти это ограничение?

    Ожидаемые ответы: «реализации сериализации», другие парсеры и реализации «из строки», а также реализация Default.

Токены разрешений

Типы-токены хорошо подходят как доказательство проверенного разрешения.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

mod admin {
    pub struct AdminToken(());

    pub fn get_admin(password: &str) -> Option<AdminToken> {
        if password == "Password123" { Some(AdminToken(())) } else { None }
    }
}

// Не нужно проверять наличие прав, потому что
// аргумент AdminToken равнозначен такой проверке.
pub fn add_moderator(_: &admin::AdminToken, user: &str) {}

fn main() {
    if let Some(token) = admin::get_admin("Password123") {
        add_moderator(&token, "CoolUser");
    } else {
        eprintln!("Incorrect password! Could not prove privileges.")
    }
}
  • В этом примере моделируется получение прав администратора в чат-клиенте по паролю и выдача пользователю звания модератора после получения этих прав. Тип AdminToken выступает в роли «доказательства корректных прав пользователя».

    Пароль запрашивается в коде; если пароль верный, мы получаем AdminToken, чтобы выполнять действия администратора в конкретной среде (здесь — в чат-клиенте).

    После получения прав можно вызвать функцию add_moderator.

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

  • Продемонстрируйте: попробуйте снова создать AdminToken в main, чтобы повторить, что основа полезных токенов — запрет на их произвольное создание.

Типы-токены с данными: охранники Mutex

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::sync::{Arc, Mutex, MutexGuard};

fn main() {
    let mutex = Arc::new(Mutex::new(42));
    let try_mutex_guard: Result<MutexGuard<'_, _>, _> = mutex.lock();
    if let Ok(mut guarded) = try_mutex_guard {
        // Полученный MutexGuard — доказательство эксклюзивного доступа.
        *guarded = 451;
    }
}
  • Mutex обеспечивает взаимное исключение при чтении и записи значения. Мы уже рассматривали Mutex ранее в этом курсе (см.: RAII/Mutex), но здесь мы смотрим именно на MutexGuard.

  • MutexGuard — это значение, которое создаёт Mutex и которое доказывает, что в данный момент у вас есть доступ на чтение и запись.

    MutexGuard также хранит ссылку на Mutex, который его создал. Реализации Deref и DerefMut дают доступ к данным Mutex, при этом сам Mutex скрывает эти данные от пользователя.

  • Если mutex.lock() не возвращает MutexGuard, у вас нет разрешения изменять значение внутри мьютекса.

    Мало того что у вас нет разрешения, у вас нет и возможности получить доступ к данным мьютекса, пока вы не получите MutexGuard.

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

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

Токены для конкретных переменных (Branding 1/4)

Что если мы хотим привязать токен к конкретной переменной?

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct Bytes {
    bytes: Vec<u8>,
}
struct ProvenIndex(usize);

impl Bytes {
    fn get_index(&self, ix: usize) -> Option<ProvenIndex> {
        if ix < self.bytes.len() { Some(ProvenIndex(ix)) } else { None }
    }
    fn get_proven(&self, token: &ProvenIndex) -> u8 {
        unsafe { *self.bytes.get_unchecked(token.0) }
    }
}

fn main() {
    let data_1 = Bytes { bytes: vec![0, 1, 2] };
    if let Some(token_1) = data_1.get_index(2) {
        data_1.get_proven(&token_1); // Работает нормально!

        // let data_2 = Bytes { bytes: vec![0, 1] };
        // data_2.get_proven(&token_1); // Паника! Можно ли это предотвратить?
    }
}
  • Что если мы хотим привязать токен к конкретной переменной в нашем коде? Можно ли сделать это в системе типов Rust?

  • Мотивация: мы хотим иметь тип-токен, который представляет известный корректный индекс в массиве байтов.

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

    Поскольку корректность индекса известна, get_proven() может пропустить проверку границ.

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

  • Продемонстрируйте: раскомментируйте строку data_2.get_proven(&token_1);.

    Здесь код паникует! Мы хотим предотвратить такое «перекрёстное» использование токенов для индексов на этапе компиляции.

  • Спросите: как можно попробовать это сделать?

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

  • Спросите: какие есть альтернативы и почему они не годятся?

    Ожидаемый ответ: проверка границ индексов во время выполнения, тем более что и Vec::get, и Bytes::get_index уже используют проверку во время выполнения.

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

  • Такая привязка токенов называется Branding. Это продвинутая техника, которая расширяет применимость типов-токенов на большее число API.

  • GhostCell — известный пример использования этой техники, к нему мы вернёмся на следующих слайдах.

PhantomData и подтипизация времён жизни (Branding 2/4)

Идея:

  • Используем время жизни как уникальную метку для каждого токена.
  • Сделаем времена жизни достаточно различными, чтобы они не преобразовывались друг в друга неявно.
// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::marker::PhantomData;

#[derive(Default)]
struct InvariantLifetime<'id>(PhantomData<&'id ()>); // Основное внимание

struct Wrapper<'a> { value: u8, invariant: InvariantLifetime<'a> }

fn lifetime_separator<T>(value: u8, f: impl for<'a> FnOnce(Wrapper<'a>) -> T) -> T {
    f(Wrapper { value, invariant: InvariantLifetime::default() })
}

fn try_coerce_lifetimes<'a>(left: Wrapper<'a>, right: Wrapper<'a>) {}

fn main() {
    lifetime_separator(1, |wrapped_1| {
        lifetime_separator(2, |wrapped_2| {
            // Мы хотим, чтобы это НЕ компилировалось
            try_coerce_lifetimes(wrapped_1, wrapped_2);
        });
    });
}
  • В Rust между временами жизни могут существовать отношения подтипизации.

    Такое отношение позволяет компилятору определить, переживает ли одно время жизни другое.

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

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

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

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

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

  • Примечание: этот слайд компилируется; к концу слайда он должен компилироваться только тогда, когда try_coerce_lifetimes закомментирован.

  • В этом коде есть две важные части:

    • Ограничение impl for<'a> на замыкание, передаваемое в lifetime_separator.
    • То, как времена жизни используются в параметре для PhantomData.

Ограничение for<'a> на замыкание

  • Мы используем for<'a>, чтобы ввести обобщённый параметр времени жизни в тип функции и потребовать, чтобы тело функции работало для всех возможных времён жизни.

    Это также лишает компилятор возможности делать предположения о конкретном времени жизни аргумента функции: тело должно соответствовать правилам анализатора заимствований независимо от «настоящего» времени жизни, которое будут иметь аргументы. Конкретное время жизни подставляет вызывающий код, сама функция сделать это не может.

    Это аналог квантора всеобщности (Ɐ) в математике или того, как мы вводим <T> как переменные типов, только для времён жизни в ограничениях трейтов.

    Когда мы пишем функцию, обобщённую по типу T, мы не можем определить этот тип изнутри самой функции. Даже если вызвать функцию fn foo<T, U>(first: T, second: U) с двумя аргументами одного типа, тело функции не может определить, одинаковы ли T и U.

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

PhantomData и вариантность времён жизни

  • Мы уже знаем PhantomData: он создаёт формальное «пустое» использование типа или параметра времени жизни, которые иначе не использовались бы.

  • Спросите: что ещё можно делать с PhantomData?

    Ожидаемые ответы: паттерн typestate, связывание времён жизни значений, которыми владеют.

  • Спросите: что такое подтипизация в других языках?

    Ожидаемые ответы: наследование, возможность использовать значение типа B там, где ожидалось значение типа A, потому что B — «подтип» A.

  • В Rust подтипизация есть! Но только для времён жизни.

    Спросите: что может означать, что одно время жизни является подтипом другого?

    Время жизни является «подтипом» другого времени жизни, когда оно переживает это другое время жизни.

  • Поведение времён жизни, используемых в PhantomData, зависит не только от того, откуда берётся время жизни, но и от того, как определена ссылка.

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

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

  • Спросите: как сделать это ограничение строже? Как сделать тип ссылки строже в Rust?

    Ожидаемый ответ или демонстрация: заменить на &'id mut (). Этого будет недостаточно!

    Нам нужна вариантность для времён жизни, при которой подтипизацию нельзя вывести, кроме как для одинаковых времён жизни. То есть единственный подтип 'a, который компилятор может знать, — это сам 'a.

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

    Продемонстрируйте переход от &'id () (ковариантно по времени жизни и типу), &'id mut () (ковариантно по времени жизни, инвариантно по типу), *mut &'id mut () (инвариантно по времени жизни и типу) к *mut &'id () (инвариантно по времени жизни, но не по типу).

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

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

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

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

Для дальнейшего изучения

Реализация Branded-типов (Branding 3/4)

Создание branded-типов отличается от создания обычных типов.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::marker::PhantomData;

#[derive(Default)]
struct InvariantLifetime<'id>(PhantomData<*mut &'id ()>);
struct ProvenIndex<'id>(usize, InvariantLifetime<'id>);

struct Bytes<'id>(Vec<u8>, InvariantLifetime<'id>);

impl<'id> Bytes<'id> {
    fn new<T>(
        // Данные, которые мы хотим изменять в этом контексте.
        bytes: Vec<u8>,
        // Функция, которая уникально помечает время жизни `Bytes`
        f: impl for<'a> FnOnce(Bytes<'a>) -> T,
    ) -> T {
        f(Bytes(bytes, InvariantLifetime::default()),)
    }

    fn get_index(&self, ix: usize) -> Option<ProvenIndex<'id>> {
        if ix < self.0.len() { Some(ProvenIndex(ix, InvariantLifetime::default())) }
        else { None }
    }

    fn get_proven(&self, ix: &ProvenIndex<'id>) -> u8 {
        debug_assert!(ix.0 < self.0.len());
        unsafe { *self.0.get_unchecked(ix.0) }
    }
}
  • Мотивация: мы хотим иметь «подтверждённые индексы» для типа и не хотим, чтобы эти индексы могли использоваться разными переменными того же типа. Также мы не хотим, чтобы эти индексы выходили за пределы области видимости.

    Нашим branded-типом будет Bytes: массив байтов.

    Нашим branded-токеном будет ProvenIndex: индекс, заведомо находящийся в допустимом диапазоне.

  • В этой реализации есть несколько важных частей:

    • new не возвращает Bytes; вместо этого он принимает «исходные данные» и одноразовое замыкание, которое получает Bytes при вызове.
    • У этой функции new в ограничении трейта есть for<'a>.
    • У нас есть и геттер для индекса, и геттер для значения по подтверждённому индексу.
  • Спросите: почему new не возвращает Bytes?

    Ответ: потому что нам нужно, чтобы у Bytes было уникальное время жизни, которым управляет API.

  • Спросите: а что, если бы new() возвращал Bytes? Какой конкретный вред это принесло бы?

    Ответ: подумайте о сигнатуре этого гипотетического метода new():

    fn new<'a>() -> Bytes<'a> { ... }

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

  • Спросите: зачем нужны и get_index, и get_proven?

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

    Спросите: тогда в чём смысл подтверждённых индексов?

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

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

Branded-типы на практике (Branding 4/4)

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::marker::PhantomData;

#[derive(Default)]
struct InvariantLifetime<'id>(PhantomData<*mut &'id ()>);
struct ProvenIndex<'id>(usize, InvariantLifetime<'id>);

struct Bytes<'id>(Vec<u8>, InvariantLifetime<'id>);

impl<'id> Bytes<'id> {
    fn new<T>(
        // Данные, которые мы хотим изменять в этом контексте.
        bytes: Vec<u8>,
        // Функция, которая уникально помечает время жизни `Bytes`
        f: impl for<'a> FnOnce(Bytes<'a>) -> T,
    ) -> T {
        f(Bytes(bytes, InvariantLifetime::default()))
    }

    fn get_index(&self, ix: usize) -> Option<ProvenIndex<'id>> {
        if ix < self.0.len() {
            Some(ProvenIndex(ix, InvariantLifetime::default()))
        } else {
            None
        }
    }

    fn get_proven(&self, ix: &ProvenIndex<'id>) -> u8 {
        self.0[ix.0]
    }
}

fn main() {
    let result = Bytes::new(vec![4, 5, 1], |mut bytes_1| {
        Bytes::new(vec![4, 2], |mut bytes_2| {
            let index_1 = bytes_1.get_index(2).unwrap();
            let index_2 = bytes_2.get_index(1).unwrap();
            bytes_1.get_proven(&index_1);
            bytes_2.get_proven(&index_2);
            // bytes_2.get_proven(&index_1); // ❌🔨
            "Computations done!"
        })
    });
    println!("{result}");
}
  • Реализация готова, теперь можно написать программу, в которой типы-токены, служащие доказательством существующих индексов, нельзя передавать между переменными.

  • Демонстрация: раскомментируйте строку bytes_2.get_proven(&index_1); и покажите, что она не компилируется, когда мы используем индексы из разных переменных.

  • Спросите: какие операции мы можем выполнить, чтобы гарантированно получить подтверждённый индекс?

    Ожидаемый ответ: реализация «push». Предлагаемая демонстрация:

    #![allow(unused)]
    fn main() {
    // Copyright 2025 Google LLC
    // SPDX-License-Identifier: Apache-2.0
    
    fn push(&mut self, value: u8) -> ProvenIndex<'id> {
        self.0.push(value);
        ProvenIndex(self.0.len() - 1, InvariantLifetime::default())
    }
    }
  • Спросите: можно ли сделать это не только для массива байтов, а как общую обёртку над Vec<T>?

    Несложно: да!

    Возможно, стоит продемонстрировать обобщение Bytes<'id> до BrandedVec<'id, T>.

  • Спросите: в каких других областях можно применить что-то подобное?

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

Для дальнейшего изучения

  • GhostCell — структура, которая позволяет создавать безопасные циклические структуры данных в Rust (наряду с другими структурами, которые раньше было трудно выразить). Она использует такой тип-токен, чтобы ячейки не могли «выйти» за пределы контекста, в котором операции, подобные показанным в этих примерах, безопасны.

    Эта серия слайдов «Branded Types» основана на реализации BrandedVec из статьи, где многие детали реализации для этого случая рассмотрены подробнее. Это мягкое введение в то, как сам GhostCell реализован и используется на практике.

    GhostCell также использует формальные проверки вне системы типов Rust, чтобы доказать, что то, что он разрешает в таком контексте (брендирование времён жизни), безопасно.

Полиморфизм

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub trait Trait {}

pub struct HasGeneric<T>(T);

pub enum Either<A, B> {
    Left(A),
    Right(B),
}

fn takes_generic<T: Trait>(value: &T) {}

fn takes_dyn(value: &dyn Trait) {}
This slide should take about 2 minutes.
  • В Rust есть множество механизмов для написания и использования полиморфного кода, но они заметно отличаются от других популярных языков!

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

Повторение

Основные возможности обобщений и полиморфизма в Rust.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub struct HasGenerics<T>(...);

pub fn uses_traits<T: Debug>(input: T) {...}

pub trait TraitBounds: Clone {...}
This slide and its sub-slides should take about 74 minutes.
  • В этом разделе мы рассмотрим базовые концепции подхода Rust к полиморфизму — то, с чем вы чаще всего будете сталкиваться в повседневной работе.

Трейты, протоколы, интерфейсы

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

trait Receiver {
    fn send(&self, message: &str);
}

struct EmailAddress(String);

impl Receiver for EmailAddress {
    fn send(&self, message: &str) {
        println!("Email to {}: {}", self.0, message);
    }
}

struct ChatId {
    uuid: [u8; 16],
}

impl Receiver for ChatId {
    fn send(&self, message: &str) {
        println!("Chat message sent to {:?}: {}", self.uuid, message);
    }
}
  • Концепция полиморфизма и обобщений в Rust во многом построена вокруг трейтов.

  • Трейты — это требования к типу в обобщённом контексте.

  • Такие требования работают почти как утиная типизация, проверяемая на этапе компиляции.

    Утиная типизация — понятие из практики динамических нетипизированных языков вроде Python: «если оно ходит как утка и крякает как утка, то это утка».

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

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

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

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

Справка:

  • https://doc.rust-lang.org/reference/items/traits.html

Ограничения трейтов для обобщённых типов

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::fmt::Display;

fn print_with_length<T: Display>(item: T) {
    println!("Item: {}", item);
    println!("Length: {}", item.to_string().len());
}

fn main() {
    let number = 42;
    let text = "Hello, Rust!";

    print_with_length(number); // Работает с целыми числами
    print_with_length(text); // Работает со строками
}
  • Чаще всего трейты используются как ограничения для обобщённых параметров типа функции или метода.

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

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

Справка:

  • https://doc.rust-lang.org/reference/trait-bounds.html

Производные трейты

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
struct BufferId([u8; 16]);

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
struct DrawingBuffer {
    target: [u8; 16],
    commands: Vec<String>,
}
  • Многие трейты, протоколы и интерфейсы имеют тривиальные реализации, которые легко написать механически.

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

    Эти макросы кто-то должен написать: компилятор не может всё понять самостоятельно.

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

    PartialEq/Eq достаточно легко вывести (derive) для типов, у которых все поля или варианты реализуют эти трейты: сопоставляем поля или варианты, и если хотя бы одно не совпадает, проверка на равенство возвращает false.

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

  • Спросите класс: приходилось ли слушателям работать с кодовой базой, где бо́льшая часть кода была тривиальным шаблонным кодом?

  • Это похоже на систему deriving в Haskell.

Справка:

  • https://doc.rust-lang.org/reference/attributes/derive.html#r-attributes.derive

Реализации методов по умолчанию

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub trait CollectLeaves {
    type Leaf;

    // Обязательный метод
    fn collect_leaves_buffered(&self, buf: &mut Vec<Self::Leaf>);

    // Реализация по умолчанию
    fn collect_leaves(&self) -> Vec<Self::Leaf> {
        let mut buf = vec![];
        self.collect_leaves_buffered(&mut buf);
        buf
    }
}
  • У трейтов часто есть методы, которые уже реализованы за вас, как только вы реализуете обязательные методы.

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

  • Часто встречаются методы, которые задают основную функциональность (например, compare у Ord), и методы, которые можно выразить через них, с реализациями по умолчанию (например, max/min/clamp у Ord).

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

Справка:

  • https://doc.rust-lang.org/reference/items/traits.html#r-items.traits.associated-item-decls

Супертрейты / зависимости трейтов

Трейты можно расширять новыми трейтами.

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub trait Animal {
    /* методы, общие для всех животных */
}

pub trait Mammal: Animal {
    /* методы только для млекопитающих */
}

// Из стандартной библиотеки

pub trait Ord: Eq + PartialOrd {
    /* методы для Ord */
}
  • Определяя трейт, можно указать, какие трейты тип тоже обязан реализовать. Такие трейты называются супертрейтами.

    В примере выше любой тип, реализующий Mammal, также обязан реализовать Animal.

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

  • Это отличается от наследования объектов! Но выглядит похоже.

    • Наследование объектов позволяет переопределять методы и по умолчанию перенимает поведение унаследованных типов.

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

Справка:

  • https://doc.rust-lang.org/reference/items/traits.html?highlight=supertrait#r-items.traits.supertraits

Обобщённые реализации трейтов (blanket)

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

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub trait PrettyPrint {
    fn pretty_print(&self);
}

// Обобщённая реализация! Если что-то реализует Display, то оно реализует
// PrettyPrint.
impl<T> PrettyPrint for T
where
    T: std::fmt::Display,
{
    fn pretty_print(&self) {
        println!("{self}")
    }
}
  • Субъектом реализации трейта в месте его определения может быть что угодно, включая T без ограничений.

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

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

    Такие реализации содержат ограничение на трейт, например impl <T: Display> ToString for T {...}

    В примере выше у нас есть обобщённая реализация для всех типов, реализующих Display. Реализация получает из ограничений трейта одну информацию: тип реализует Display::fmt.

    Этого достаточно, чтобы написать реализацию красивого вывода в консоль.

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

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

Справка:

  • https://doc.rust-lang.org/reference/glossary.html#blanket-implementation

Условные реализации методов

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

// Нет ограничений трейтов в определении типа.
pub struct Value<T>(T);

// Вместо этого ограничения накладываются на реализации для типа.
impl<T: std::fmt::Display> Value<T> {
    fn log(&self) {
        println!("{}", self.0);
    }
}

// или так
impl<T> Value<T> {
    // Задаёт ограничение трейта в выражении where
    fn log_error(&self)
    where
        T: std::error::Error,
    {
        eprintln!("{}", self.0);
    }
}
  • Определяя тип с обобщёнными параметрами, мы можем писать реализации для этого типа, которые зависят от того, какими являются параметры или какие трейты они реализуют.

  • Такие методы доступны только тогда, когда тип удовлетворяет этим условиям.

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

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

    С условными реализациями методов инварианты поддерживаются прекрасно.

Правило сироты

Что мешает пользователям писать произвольные реализации трейтов для любого типа?

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

// Крейт `postgresql-bindings`

pub struct PostgresqlConn(/* подробности */);

// Крейт `database-traits`, зависит от `postgresql-bindings`

pub trait DbConnection {
    /* методы */
}

impl DbConnection for PostgresqlConn {} // ✅, `DbConnection` локальный.

// Крейт `mycoolnewdb` зависит от `database-traits`

pub struct MyCoolNewDbConn(/* подробности */);

impl DbConnection for MyCoolNewDbConn {} // ✅, `MyCoolNewDbConn` локальный.

// Ни `PostgresqlConn`, ни `DbConnection` не являются локальными для `mycoolnewdb`.
// Это привело бы к двум реализациям `DbConnection` для `PostgresqlConn`!
impl DbConnection for PostgresqlConn {} // ❌🔨
  • Трейт в экосистеме Rust никогда не должен реализовываться дважды. Две реализации одного трейта для одного типа — это конфликт, который не имеет решения.

  • Внутри крейта это можно предотвратить, обнаруживая множественные определения и запрещая их, но что насчёт разных крейтов во всей экосистеме Rust?

  • Типы либо локальны для крейта (определены в нём), либо нет.

    В примере «крейтов» PostgresqlConn локален для postgresql-bindings, а MyCoolNewDbConn локален для mycoolnewdb.

  • Трейты тоже либо локальны для крейта, либо нет.

    И снова в примере трейт DbConnection локален для database-traits.

  • Если что-то локально, для него можно писать реализации трейтов.

    Если трейт локален, его реализации можно писать для любого типа.

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

  • За пределами этих границ реализации трейтов написать нельзя.

    Это сохраняет реализации «согласованными»: на весь набор крейтов может существовать только одна реализация трейта для типа.

Справка:

  • https://doc.rust-lang.org/stable/reference/items/implementations.html#r-items.impl.trait.orphan-rule

Типы известного размера и динамически размерные типы

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::fmt::Debug;

pub struct AlwaysSized<T /* : Sized */>(T);

pub struct OptionallySized<T: ?Sized>(T);

type Dyn1 = OptionallySized<dyn Debug>;
  • Мотивация: возможность различать типы, размер которых известен на этапе компиляции, и типы, размер которых известен во время выполнения, полезна для

  • Трейт Sized автоматически реализуется типами, размер которых известен на этапе компиляции.

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

  • Большинство типов реализуют Sized: их размер известен на этапе компиляции.

    Типы вроде [T], str и dyn Trait — динамически размерные. Их размер хранится как часть ссылки на значение этого типа.

  • Параметры типа по умолчанию реализуют Sized, если не указано иное.

Справка:

  • https://doc.rust-lang.org/stable/reference/dynamically-sized-types.html#r-dynamic-sized

Мономорфизация и размер бинарного файла

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn print_vec<T: std::fmt::Debug>(debug_vec: &Vec<T>) {
    for item in debug_vec {
        println!("{:?}", item);
    }
}

fn main() {
    let ints = vec![1u32, 2, 3];
    let floats = vec![1.1f32, 2.2, 3.3];

    // первый экземпляр, &Vec<u32> -> ()
    print_vec(&ints);
    // второй экземпляр, &Vec<f32> -> ()
    print_vec(&floats);
}
  • Каждый экземпляр функции или типа с обобщёнными параметрами на этапе компиляции превращается в уникальную конкретную версию этой функции. Обобщений во время выполнения не существует, есть только конкретные типы.

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

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

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

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

От ООП к Rust: композиция вместо наследования

  • Наследование — ключ к успеху ООП как парадигмы. Десятилетия успешной разработки ПО строились на наследовании как основе бизнес-логики.

  • Так почему же Rust отказался от наследования?

  • Как перейти от решения задач через наследование к подходу Rust?

  • Как представлять разнородные коллекции в Rust?

This slide and its sub-slides should take about 106 minutes.
  • В этом разделе мы разберём, как перейти от полиморфного решения задач с типами в ООП-языках вроде Java, C++ и др. к основанному на трейтах подходу Rust к полиморфизму.

  • Будут и различия, но есть и много общего — особенно с современными стандартами ООП-разработки. Сохраняйте открытость.

Наследование в ООП-языках

// Базовый класс
class Vehicle {
public:
    void accelerate() { }
    void brake() { }
};

// Наследующий класс
class Car : public Vehicle {
public:
    void honk() { }
};

int main() {
    Car myCar;                  // Создаём объект Car
    myCar.accelerate();         // Унаследованный метод
    myCar.honk();               // Собственный метод Car
    myCar.brake();              // Унаследованный метод
    return 0;
}
  • Это короткое напоминание слушателям о том, что такое наследование в других языках.

  • Наследование — механизм, при котором «дочерний» тип получает поля и методы «родительских» типов, от которых наследуется.

  • Наследующий тип может переопределять методы по мере необходимости.

  • Методы унаследованных классов можно вызывать через super.

Почему в Rust нет наследования?

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub struct Id {
    pub id: u32
}

impl Id {
    // методы
}

// 🔨❌, в Rust нет наследования!
pub struct Data: Id {
    // Унаследованное поле "id"
    pub name: String,
}

impl Data {
    // методы, но также включает методы Id или, возможно, переопределения
    // этих методов.
}

// ✅
pub struct Data {
    pub id: Id,
    pub name: String,
}

impl Data {
    // Все методы data, которые не относятся к трейтам.
}

impl SomeTrait for Data {
    // Реализации трейтов в отдельных блоках impl.
}
  • У наследования есть ряд недостатков.

  • Разнородность по умолчанию:

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

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

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

    Поля типа скрыты иерархией наследования.

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

  • Динамическая диспетчеризация по умолчанию добавляет накладные расходы на поиск в vtable:

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

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

Наследование с точки зрения Rust

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

// Данные
pub struct Data {
    id: usize,
    name: String,
}

// Конкретное поведение
impl Data {
    fn new(id: usize, name: impl Into<String>) -> Self {
        Self { id, name: name.into() }
    }
}

// Абстрактное поведение
trait Named {
    fn name(&self) -> &str;
}

// Поведение экземпляра
impl Named for Data {
    fn name(&self) -> &str {
        &self.name
    }
}
  • С точки зрения Rust, где наследования изначально не было, введение наследования выглядело бы как размывание границы между типами и трейтами.

  • Тип — это конкретный фрагмент данных и связанное с ним поведение.

    Трейт — это абстрактное поведение, которое должен реализовать тип.

    Класс — это сочетание данных, поведения и переопределений этого поведения.

  • С точки зрения Rust наследуемый класс выглядит как тип, который одновременно является трейтом.

  • Это не плюс: мы больше не можем рассуждать о конкретных типах.

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

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

«Наследование» в Rust: супертрейты

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub trait SuperTrait {}

pub trait Trait: SuperTrait {}
  • В Rust трейты могут зависеть от других трейтов. Мы уже знакомы с тем, что у трейтов могут быть супертрейты.

  • Внешне это похоже на наследование.

  • Это механизм, похожий на наследование, но он отделяет данные от поведения.

  • Поведение остаётся в таком состоянии, в котором его легко анализировать.

  • Также это упрощает то, чего мы хотим добиться с помощью «множественного наследования»:

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

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

  • Наследования полей не происходит. Трейт не открывает поля, только методы, ассоциированные типы и константы.

Композиция вместо наследования

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub struct Uuid([u8; 16]);

pub struct Address {
    street: String,
    city_or_province: String,
    code: String,
    country: String,
}

pub struct User {
    id: Uuid,
    address: Address,
}
  • Вместо миксинов или наследования мы составляем типы из полей разных типов.

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

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

dyn Trait для динамической диспетчеризации в Rust

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub trait Trait {}

impl Trait for i32 {}
impl Trait for String {}

fn main() {
    let int: &dyn Trait = &42i32;
    let string: &dyn Trait = &String::from("Hello dyn!");;
}
  • Динамическая диспетчеризация — инструмент объектно-ориентированного программирования, который часто используют там, где важнее поведение типа, а не то, чем тип является.

    В ООП-языках динамическая диспетчеризация часто происходит неявно, и от неё нельзя отказаться.

    В Rust используется dyn Trait: динамическая диспетчеризация, которую нужно включить явно.

  • Для любого трейта, который совместим с dyn, можно привести ссылку на значение этого трейта к значению dyn Trait.

  • Их называют объектами трейтов. Их тип неизвестен на этапе компиляции, но поведение известно: оно задаётся самим трейтом.

  • Когда вам действительно нужны разнородные структуры данных в стиле ООП, можно использовать Box<dyn Trait>, но сначала постарайтесь сохранить однородность и опираться на обобщения!

Трейты, совместимые с dyn

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub trait Trait {
    // совместим с dyn
    fn takes_self(&self);

    // совместим с dyn, но этот метод нельзя использовать через dyn
    fn takes_self_and_param<T>(&self, input: &T);

    // больше не совместим с dyn
    const ASSOC_CONST: i32;

    // больше не совместим с dyn
    fn clone(&self) -> Self;
}
  • Не все трейты можно вызывать как объекты трейтов. Трейт, который можно так вызывать, называется совместимым с dyn.

  • Раньше это называлось object safe traits или object safety.

  • Динамическая диспетчеризация переносит большой объём информации о типах, известной на этапе компиляции, в информацию vtable, которая существует во время выполнения.

    Если концепция несовместима с тем, что осмысленно хранить в vtable, то либо трейт перестаёт быть совместимым с dyn, либо эти методы исключаются из возможных вызовов в контексте dyn.

  • Трейт совместим с dyn, если все его супертрейты совместимы с dyn, у него нет ассоциированных констант и типов и нет методов, зависящих от обобщений.

  • Чаще всего вы столкнётесь с трейтами, несовместимыми с dyn, когда у них есть ассоциированные типы или константы либо когда они возвращают Self (например, трейт Clone несовместим с dyn).

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

    Для таких методов, как clone, это исключает совместимость с dyn, потому что тип результата зависит от конкретного типа self.

Справка:

  • https://doc.rust-lang.org/1.91.1/reference/items/traits.html#r-items.traits.dyn-compatible

Обобщённые параметры функций и dyn Trait

Есть два способа писать полиморфные функции. Чем они отличаются?

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn print_display<T: std::fmt::Display>(t: &T) {
    println!("{}", t);
}

fn print_display_dyn(t: &dyn std::fmt::Display) {
    println!("{}", t);
}

fn main() {
    let int = 42i32;
    // Мономорфизируется в отдельную функцию для входных значений i32.
    print_display(&int);
    // По одной на каждый
    print_display_dyn(&int);
}
  • Полиморфные функции можно писать над обобщениями или над объектами трейтов.

  • При написании функций с обобщёнными параметрами для каждого уникального типа, который подставляется вместо параметра, генерируется новая версия функции.

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

  • При написании функций, принимающих объект трейта, в итоговом бинарном файле будет существовать только одна версия такой функции (не считая инлайнинга).

  • Обобщённые параметры не имеют стоимости, кроме размера бинарного файла. Типы должны быть однородными: все экземпляры T могут быть только одного типа.

Ограничения объектов трейтов

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::any::Any;

pub trait Trait: Any {}

impl Trait for i32 {}

fn main() {
    dbg!(size_of::<i32>()); // 4 байта, значение с владением
    dbg!(size_of::<&i32>()); // 8 байт, ссылка
    dbg!(size_of::<&dyn Trait>()); // 16 байт, широкий указатель
}
  • Объекты трейтов — ограниченный способ решения задач.

  • Если нужно привести объект трейта обратно к конкретному типу (downcast), придётся указать, что соответствующий трейт имеет Any в качестве супертрейта, или что объект трейта строится над основным трейтом и Any.

    Даже в этом случае всё равно придётся привести dyn MyTrait к dyn Any.

  • Объекты трейтов создают накладные расходы по памяти: это «широкие указатели», которые должны хранить не только указатель на сами данные, но и ещё один указатель на vtable.

  • Объекты трейтов — динамически размерные типы, поэтому на практике их можно использовать только через ссылки или указатели.

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

Разнородные данные с dyn trait

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::fmt::Display;

pub struct Lambda;

impl Display for Lambda {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "λ")
    }
}

fn main() {
    let heterogeneous: Vec<Box<dyn Display>> = vec![
        Box::new(42u32),
        Box::new(String::from("Woah")),
        Box::new(Lambda),
    ];
    for item in heterogeneous {
        // Мы знаем, что «item» реализует Display, но больше ничего не знаем!
        println!("Display output: {}", item);
    }
}
  • dyn Trait как инструмент динамической диспетчеризации позволяет хранить разнородные данные в коллекциях.

  • В этом примере мы храним типы, которые все реализуют std::fmt::Display, и выводим все элементы коллекции на экран.

Трейт Any и downcast

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::any::Any;

#[derive(Debug)]
pub struct ThisImplementsAny;

fn take_any<T: Any>(t: &T) {}

fn main() {
    let is_an_any = ThisImplementsAny;
    take_any(&is_an_any);

    let dyn_any: &dyn Any = &is_an_any;
    dbg!(dyn_any.type_id());
    dbg!(dyn_any.is::<ThisImplementsAny>());
    let is_downcast: Option<&ThisImplementsAny> = dyn_any.downcast_ref();
    dbg!(is_downcast);
}
  • Трейт Any позволяет привести значения из dyn-значений обратно к конкретным значениям (downcast).

  • Это автотрейт: как и Send/Sync/Sized, он автоматически реализуется для любого типа, который удовлетворяет определённым критериям.

  • Критерий для Any: тип должен быть 'static, то есть не содержать внутри себя времён жизни, кроме 'static.

  • Any предоставляет два связанных поведения: downcast и проверку во время выполнения того, совпадают ли типы.

    В примере выше видно, как downcast из Any в ThisImplementsAny выполняется автоматически.

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

  • Any не реализует рефлексию для типа; больше с Any ничего не сделать.

Ловушка: слишком быстрое обращение к dyn Trait

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::any::Any;

pub trait AddDyn: Any {
    fn add_dyn(&self, rhs: &dyn AddDyn) -> Box<dyn AddDyn>;
}

impl AddDyn for i32 {
    fn add_dyn(&self, rhs: &dyn AddDyn) -> Box<dyn AddDyn> {
        if let Some(downcast) = (rhs as &dyn Any).downcast_ref::<Self>() {
            Box::new(self + downcast)
        } else {
            Box::new(*self)
        }
    }
}

fn main() {
    let i: &dyn AddDyn = &42;
    let j: &dyn AddDyn = &64;
    let k: Box<dyn AddDyn> = i.add_dyn(j);
    dbg!((k.as_ref() as &dyn Any).is::<i32>());
    dbg!((k.as_ref() as &dyn Any).downcast_ref::<i32>());
}
  • Тем, кто пришёл из ООП, понятно желание как можно раньше взяться за этот инструмент динамической диспетчеризации.

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

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

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

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

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

    Сложив два значения, чтобы их вывести, мы должны снова привести их (downcast) к «настоящему» типу, который можно вывести, учитывая ограничения трейтов, связанные с операцией до этого момента.

  • Спросите класс: почему нельзя просто добавить ограничения Display в main, чтобы выводить значения как есть?

    Ответ: потому что add_dyn возвращает только dyn AddDyn, и мы теряем информацию о том, что реализует тип, между типом аргументов и типом результата. Даже если входные значения реализуют Display, тип результата — нет.

  • Это приводит к менее производительному коду, который труднее понять.

Sealed-трейты: пользователи не могут их расширять

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

// крейт может обращаться к модулю «sealed» и его трейту, а проекты,
// которые от него зависят, — нет.
mod sealed {
    pub trait Sealed {}
    impl Sealed for String {}
    impl Sealed for Vec<u8> {}
    //...
}

pub trait APITrait: sealed::Sealed {
    /* методы */
}
impl APITrait for String {}
impl APITrait for Vec<u8> {}
  • Мотивация: мы хотим, чтобы в крейте был код, управляемый трейтами, но не хотим, чтобы проекты, зависящие от этого крейта, могли реализовывать трейт.

Почему?

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

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

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

  • Почему бы не использовать перечисления?

    • Перечисления раскрывают детали реализации — «это работает для этих типов».

    • Пользователям нужно использовать конструкторы вариантов перечисления, чтобы работать с API.

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

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

Запечатывание с помощью перечислений

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::collections::BTreeMap;
pub enum GetSource {
    WebUrl(String),
    BytesMap(BTreeMap<String, Vec<u8>>),
}

impl GetSource {
    fn get(&self, url: &str) -> Option<&Vec<u8>> {
        match self {
            Self::WebUrl(source) => unimplemented!(),
            Self::BytesMap(map) => map.get(url),
        }
    }
}
  • Мотивация: API спроектирован вокруг конкретного списка допустимых для него типов, и пользователи API не должны его расширять.

  • Перечисления в Rust — это алгебраические типы данных: для каждого варианта можно определить свою структуру.

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

  • Когда пользовательская часть API ссылается на перечисление, пользователи знают, какие типы допустимы в качестве входных данных, и могут создавать эти типы доступными для этого методами.

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

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

Трейты для полиморфизма, которые пользователи могут расширять

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

// Крейт A

pub trait Trait {
    fn use_trait(&self) {}
}

// Крейт B, зависит от A

pub struct Data(u8);

impl Trait for Data {}

fn main() {
    let data = Data(7u8);
    data.use_trait();
}
  • Обычные трейты мы уже подробно рассмотрели, но по сравнению с перечислениями и sealed-трейтами они позволяют пользователям расширять API, реализуя поведение, которое API от них требует.

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

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

Решение задач: разбейте задачу на части

// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

// Задача: реализация API для GUI

// Вопрос: какое минимальное полезное поведение нужно API для рисования?
pub trait DrawApi {
    fn arc(&self, center: [f32; 2], radius: f32, start_angle: f32, end_angle: f32);
    fn line(&self, start: [f32; 2], end: [f32; 2]);
}

pub struct TextDraw;

impl DrawApi for TextDraw {
    fn arc(&self, center: [f32; 2], radius: f32, start_angle: f32, end_angle: f32) {
        println!("arc of radius ")
    }

    fn line(&self, start: [f32; 2], end: [f32; 2]) { /* ... */
    }
}

// Вопрос: какой API будет удобен пользователям?

pub trait Draw {
    fn draw<T: DrawApi>(&self, surface: &mut T);
}

pub struct Rect {
    start: [f32; 2],
    end: [f32; 2],
}

impl Draw for Rect {
    fn draw<T: DrawApi>(&self, surface: &mut T) {
        surface.line([self.start[0], self.start[1]], [self.end[0], self.start[1]]);
        surface.line([self.end[0], self.start[1]], [self.end[0], self.end[1]]);
        surface.line([self.end[0], self.end[1]], [self.start[0], self.end[1]]);
        surface.line([self.start[0], self.end[1]], [self.start[0], self.start[1]]);
    }
}
  • Вы уже умеете разбивать задачи на части, но, скорее всего, привыкли опираться на методы в стиле ООП.

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

  • Сначала попробуйте решить задачу с помощью обобщений и трейтов или перечислений.

    Требует ли задача определённого набора типов? Перечисление может оказаться самым чистым способом её решения.

    Действительно ли задаче важны конкретные детали задействованных типов или можно сосредоточиться на поведении?

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

    Существует ли уже трейт для этого случая? Если да, используйте его!

  • Если вам действительно нужны разнородные коллекции, используйте их! В Rust они существуют как инструмент не просто так.

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

    То есть прежде чем использовать динамическую диспетчеризацию с объектами трейтов, убедитесь, что она действительно нужна.

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

ВАЖНО: ЭТОТ МОДУЛЬ НАХОДИТСЯ НА РАННЕЙ СТАДИИ РАЗРАБОТКИ

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

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

Добро пожаловать в углублённый разбор небезопасного Rust

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

Мы будем работать над тремя направлениями:

  • формирование ментальной модели небезопасного Rust
  • практика чтения и написания кода на небезопасном Rust
  • практика ревью кода на небезопасном Rust

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

  • Формирование ментальной модели небезопасного Rust

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

    • чтение и написание как кода, так и документации
    • использование небезопасных API
    • проектирование и реализация таких API
  • Ревью кода

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

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

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

  • Кто вы?
  • Над чем вы работаете?
  • Какие у вас цели на этот курс?

Подготовка к работе

Локальная установка Rust

У вас должен быть установлен компилятор Rust с поддержкой редакции языка 2024 года — то есть любая версия rustc новее 1.84.

$ rustc --version 
rustc 1.87

(По желанию) Создайте локальную копию курса

$ git clone --depth=1 https://github.com/r007/EmbeddedRust-Rus.git
Cloning into 'EmbeddedRust-Rus'...
...
$ cd EmbeddedRust-Rus/comprehensive-rust
$ cargo install-tools
...
$ cargo serve # затем откройте http://127.0.0.1:3000/ в браузере
This slide should take about 2 minutes.

Попросите всех убедиться, что они могут запустить rustc версии новее 1.87.

Тем, у кого не получится, скажите, что мы разберёмся с этим на перерыве.

Введение

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

План

Этот сегмент займёт около 1 часа 10 минут. Он включает:

СлайдДлительность
Что такое небезопасный Rust5 минут
Назначение ключевого слова unsafe5 минут
Две роли ключевого слова unsafe5 минут
Разминочные примеры25 минут
Особенности небезопасного Rust15 минут
Перенос ответственности3 минуты
Требуется более строгий процесс разработки5 минут
Пример: may_overflow10 минут

Что такое небезопасный Rust

SafeRustUnsafeRust
This slide should take about 5 minutes.

«Небезопасный Rust — это надмножество безопасного Rust».

«Небезопасный Rust добавляет дополнительные возможности, например разыменование сырых указателей и вызов функций, которые при неправильном вызове могут нарушить гарантии безопасности Rust».

«Эти дополнительные возможности называются небезопасными операциями».

«Небезопасные операции составляют основу, на которой построена стандартная библиотека Rust. Например, без возможности разыменовать сырой указатель невозможно было бы реализовать Vec или Box».

«Компилятор по-прежнему помогает вам при написании небезопасного Rust. Проверка заимствований и типобезопасность по-прежнему действуют. У небезопасных операций есть свои правила, с которыми мы познакомимся на этом курсе».

Небезопасные операции из справочника Rust (не тратьте на них слишком много времени):

Следующие возможности языка нельзя использовать в безопасном подмножестве Rust:

  • Разыменование сырого указателя.
  • Чтение или запись изменяемой или небезопасной внешней статической переменной.
  • Доступ к полю объединения (union), кроме присваивания ему значения.
  • Вызов функции unsafe.
  • Вызов безопасной функции, помеченной <target_feature>, из функции, у которой нет атрибута <target_feature>, включающего те же возможности.
  • Реализация небезопасного трейта.
  • Объявление блока extern.
  • Применение небезопасного атрибута к элементу.

Зачем существует ключевое слово unsafe

  • Rust обеспечивает безопасность
  • Но возможности компилятора ограничены
  • Ключевое слово unsafe позволяет программистам взять на себя ответственность за соблюдение правил Rust
This slide should take about 5 minutes.

«Фундаментальная цель Rust — обеспечить безопасность памяти».

«Но есть и ограничения. Некоторые соображения безопасности нельзя выразить в языке программирования. Даже если бы их можно было выразить, возможности контроля со стороны компилятора Rust ограничены».

«Ключевое слово unsafe переносит бремя соблюдения правил Rust с компилятора на программиста».

«Когда вы видите ключевое слово unsafe, вы видите, как ответственность переходит от компилятора к программисту».

У ключевого слова unsafe две роли

  1. Создание API с соображениями безопасности

    • небезопасные функции: unsafe fn get_unchecked(&self) { ... }
    • небезопасные трейты: unsafe trait Send {}
  2. Использование API с соображениями безопасности

    • вызов встроенных небезопасных операторов: unsafe { *ptr }
    • вызов небезопасных функций: unsafe { x.get_unchecked() }
    • реализация небезопасных трейтов: unsafe impl Send for Counter {}
This slide should take about 5 minutes.

Две роли:

  1. Создание API с соображениями безопасности и определение того, что нужно учитывать
  2. Использование API с соображениями безопасности и подтверждение того, что эти соображения учтены

Создание API с соображениями безопасности

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

«В этой роли вы сообщаете пользователям вашего API, что им нужно быть осторожными».

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

Использование API с соображениями безопасности

«Ключевое слово unsafe выполняет свою вторую роль — использование API — когда оно стоит рядом с фигурной скобкой».

«В этой роли ключевое слово unsafe означает, что автор был внимателен. Он проверил, что код безопасен, и дал гарантию другим».

«Небезопасные блоки встречаются чаще всего. Они позволяют вызывать небезопасные функции, определённые в первой роли».

«Небезопасные блоки также позволяют выполнять операции, которые компилятор знает как небезопасные, например разыменование сырого указателя».

«Ключевое слово unsafe также может использоваться для реализации небезопасных трейтов».

Разминочные примеры

Примеры для демонстрации:

Использование блока unsafe

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let numbers = vec![0, 1, 2, 3, 4];
    let i = numbers.len() / 2;

    let x = *numbers.get_unchecked(i);
    assert_eq!(i, x);
}

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

Попробуйте скомпилировать код и вызовите ошибку компилятора.

Добавьте небезопасный блок:

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let numbers = vec![0, 1, 2, 3, 4];
    let i = numbers.len() / 2;

 let x = unsafe { *numbers.get_unchecked(i) };
    assert_eq!(i, x);
}

Попросите аудиторию провести ревью кода. Подводите слушателей к добавлению комментария о безопасности.

Добавьте комментарий о безопасности:

#![allow(unused)]
fn main() {
// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

// SAFETY: `i` должен находиться в диапазоне 0..numbers.len()
}

Предлагаемое решение

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let numbers = vec![0, 1, 2, 3, 4];
    let i = numbers.len() / 2;

    let x = unsafe { *numbers.get_unchecked(i) };
    assert_eq!(i, x);
}

Определение небезопасной функции

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Преобразует указатель, который может быть нулевым, в ссылку.
///
/// Возвращает `None`, если `p` равен null, иначе оборачивает `val` в `Some`.
fn ptr_to_ref<'a, T>(ptr: *mut T) -> Option<&'a mut T> {
    if ptr.is_null() {
        None
    } else {
        // SAFETY: `ptr` не равен null
        unsafe { Some(&mut *ptr) }
    }
}

«Это выглядит как безопасный код, однако на самом деле требуется небезопасный блок».

Обратите внимание на операцию разыменования, то есть на *p внутри небезопасного блока.

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

«Это может показаться контринтуитивным, но многие указатели нельзя преобразовать в ссылки».

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

«Итак, как проектировщики API, у нас есть два пути. Мы можем либо взять на себя ответственность за защиту от некорректных входных данных, либо переложить эту ответственность на вызывающий код с помощью ключевого слова unsafe».

«Первый путь трудный. Мы принимаем обобщённый тип T, то есть все возможные типы, реализующие Sized. Это очень много типов!»

«Поэтому второй путь выглядит разумнее».

Дополнительный материал (если останется время)

«Кстати, если вас интересуют подробности указателей и правила их преобразования в ссылки, в стандартной библиотеке много полезной документации. Также стоит посмотреть исходный код многих методов std::pointer».

«Например, функция ptr_to_ref с этого слайда на самом деле существует в стандартной библиотеке как метод as_mut у указателей».

Откройте документацию std::pointer.as_mut и обратите внимание на раздел Safety.

Реализация небезопасного трейта

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub struct LogicalClock {
    inner: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}

// ...

impl Send for LogicalClock {}
impl Sync for LogicalClock {}

«Прежде чем смотреть на код, стоит убедиться, что все знают, что такое трейт. Может ли кто-нибудь объяснить трейты остальным участникам курса?»

  • «Трейты часто описывают как способ создать общее поведение. Если думать о трейтах как об общем поведении, внимание сосредотачивается на синтаксисе методов и их сигнатурах».
  • «Есть и более глубокий способ думать о трейтах: как о наборах требований. Такой взгляд подчёркивает общую семантику реализующих типов».

«Может ли кто-нибудь объяснить, что такое трейты Send и Sync?»

  • Если никто не может
    • «Send и Sync относятся к конкурентности. Деталей много, но в общих чертах: типы Send можно передавать между потоками по значению, а типы Sync должны разделяться по ссылке».
    • Чтобы безопасно разделять данные между потоками, нужно соблюдать множество правил. Компилятор не может проверить эти правила, поэтому автор кода должен взять на себя ответственность за их соблюдение.
    • Arc реализует Send и Sync, поэтому и для нашего clock безопасно реализовать их тоже.
    • Может быть полезно заметить, что слово atomic происходит из древнегреческого и означает «неделимый» или «цельный», а не современный английский смысл «крошечная частица».

Определение небезопасного трейта

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Указывает, что тип занимает 32 бита памяти.
pub trait Size32 {}

«Теперь определим собственный небезопасный трейт».

Добавьте ключевое слово unsafe и скомпилируйте код.

«Если требования трейта семантические, трейту, возможно, вообще не понадобятся методы. Однако документация необходима».

«Трейты без методов называются маркерными трейтами. Реализуя их для типов, вы добавляете информацию в систему типов. Теперь компилятор может оперировать типами, которые удовлетворяют требованиям, описанным в документации».

Характеристики unsafe

Unsafe опасен

«Использование после освобождения (UAF), переполнения целых чисел и чтение/запись за границами массива (OOB) составляют 90% уязвимостей, причём OOB встречаются чаще всего».

— Jeff Vander Stoep and Chong Zang, Google. “Queue the Hardening Enhancements”

«В индустрии ПО накоплено много доказательств того, что небезопасный код трудно написать правильно и что он порождает очень серьёзные проблемы».

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

«Будьте осторожны».

Unsafe иногда необходим

Компилятор Rust может применять свои правила только к коду, который он скомпилировал.

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let pid = unsafe { libc::getpid() };
    println!("{pid}");
}

«Некоторые действия требуют unsafe».

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

Необязательно:

«Работа с внешним окружением часто подразумевает совместное использование памяти. Интерфейс, который предоставляют компьютеры, — это адрес памяти (указатель)».

«Вот пример, в котором мы просим ядро Linux записать данные в память, которую контролируем мы:»

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let mut buf = [0u8; 8];
    let ptr = buf.as_mut_ptr() as *mut libc::c_void;

    let status = unsafe { libc::getrandom(ptr, buf.len(), 0) };
    if status > 0 {
        println!("{buf:?}");
    }
}

«Этот вызов FFI обращается к операционной системе, чтобы заполнить наш буфер (buf). Помимо вызова внешней функции, границу нужно пометить как unsafe, потому что компилятор не может проверить, как ОС работает с этой памятью».

Unsafe иногда полезен

Ваш код может работать быстрее!

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn iter_sum(xs: &[u64]) -> u64 {
    xs.iter().sum()
}

fn fast_sum(xs: &[u64]) -> u64 {
    let mut acc = 0;
    let mut i = 0;
    unsafe {
        while i < xs.len() {
            acc += *xs.get_unchecked(i);
            i += 1;
        }
    }
    acc
}

fn main() {
    let data: Vec<_> = (0..1_000_000).collect();

    let baseline = iter_sum(&data);
    let unchecked = fast_sum(&data);

    assert_eq!(baseline, unchecked);
}

Код с unsafe может работать быстрее.

fast_sum() пропускает проверки границ. Однако утверждения о производительности нужно проверять бенчмарками. В подобных случаях итераторы Rust обычно и так устраняют проверки границ.

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

Ключевое слово unsafe переносит ответственность

Безопасна ли память?Ответственность за безопасность памяти
Безопасный RustДаКомпилятор
Небезопасный код в RustДаПрограммист
This slide should take about 3 minutes.

Кто отвечает за безопасность памяти?

  • Безопасный Rust → компилятор
  • Небезопасный Rust → программист

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

«Ключевое слово unsafe переносит ответственность за поддержание безопасности памяти с компилятора на программистов. Оно сигнализирует, что есть предусловия, которые должны выполняться».

«Чтобы выполнять эту ответственность, программисты должны убедиться, что они поняли, каковы предусловия, и что их код всегда им удовлетворяет».

«На протяжении всего курса мы будем использовать термин предусловия безопасности для описания этой ситуации».

Влияние на рабочий процесс

Во время написания кода

  • Убедитесь, что понимаете предусловия любых функций и трейтов unsafe
  • Проверьте, что предусловия выполнены
  • Документируйте свои рассуждения в комментариях о безопасности

Усиленное ревью кода

  • Самопроверка → коллега-ревьюер → эксперт по небезопасному Rust (при необходимости)
  • Передайте вопрос человеку, который хорошо разбирается в вашем коде и ходе рассуждений
This slide should take about 5 minutes.

«Ключевое слово unsafe возлагает на программиста больше ответственности, поэтому требует более строгого процесса разработки».

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

«Автор и основной ревьюер сами проверяют простой небезопасный код Rust и передают вопрос эксперту по unsafe, когда это необходимо».

«Экспертов по небезопасному Rust немного, и они очень заняты, поэтому их время нужно использовать оптимально».

Пример: функция may_overflow

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Прибавляет 2^31 - 1 к отрицательным числам без проверки переполнения.
///
/// # Safety
///
/// Вызов с `a >= 1` приводит к переполнению знакового целого, что является
/// неопределённым поведением при использовании `unchecked_add`.
unsafe fn may_overflow(a: i32) -> i32 {
    unsafe { a.unchecked_add(i32::MAX) }
}

fn main() {
    let x = unsafe { may_overflow(123) };
    println!("{x}");
}
This slide should take about 10 minutes.

«Ключевое слово unsafe может иметь немного иной смысл, чем предполагают некоторые».

«Автор кода считает, что код корректен. В принципе, код безопасен».

«В этом игрушечном примере функция may_overflow предназначена для вызова только с отрицательными числами».

Спросите слушателей, могут ли они объяснить, почему may_overflow требует ключевого слова unsafe.

«Если вы не уверены, в чём проблема, давайте ненадолго остановимся и объясним. Для положительных чисел в i32 доступно только 31 бит».

В стандартном безопасном Rust переполнение целого (например, a + i32::MAX) не является неопределённым поведением: при включённых проверках переполнения (отладочная сборка) программа паникует, а в релизной сборке выполняется перенос в дополнительном коде. Однако unchecked_add — небезопасная операция, которая полностью пропускает проверки переполнения и объявляет переполнение неопределённым поведением (UB). Компиляторы оптимизируют код, исходя из того, что неопределённое поведение невозможно, и это может привести к удалению «мёртвого» кода и неожиданному поведению во время выполнения.

Скомпилируйте и запустите код: в отладочной сборке стандартная библиотека обнаруживает нарушение предусловия безопасности и паникует (unsafe precondition(s) violated: i32::unchecked_add cannot overflow). В релизной сборке unchecked_add порождает настоящее UB.

«Этот код можно использовать корректно, однако неправильное использование крайне опасно».

«А компилятор не может проверить, что использование корректно».

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

Предусловия безопасности

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

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

«Вероятно, вы заметите сильную связь между предусловиями безопасности и правилами безопасного Rust».

Вопрос: можете назвать какие-нибудь?

(Более полный список на следующем слайде)

Распространённые предусловия безопасности

  • Псевдонимы и изменяемость
  • Выравнивание
  • Доступ к массиву в границах
  • Инициализация
  • Время жизни ссылок
  • Происхождение указателя (pointer provenance)
  • Корректность значения (validity)
  • Память
This slide and its sub-slides should take about 6 minutes.

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

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

  • Корректность значения (validity). Значения должны быть корректными значениями того типа, который они представляют. Ссылки Rust не могут быть null. Создание такой ссылки через unsafe приводит к
  • Выравнивание (alignment). Ссылки на значения должны быть правильно выровнены, что означает, что
  • Псевдонимы (aliasing). Весь код Rust должен соблюдать правила заимствования Rust. Если вы вручную создаёте изменяемые ссылки (&mut T) из указателей, то можете создать только одну
  • Инициализация. Все экземпляры типов Rust должны быть полностью инициализированы. Чтобы создать значение из сырой памяти, нужно убедиться, что мы записали
  • Происхождение указателя (pointer provenance). Происхождение указателя важно. Приведение usize к сырому указателю больше не допускается.
  • Времена жизни. Ссылки не должны переживать то, на что они указывают.

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

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

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

Необязательно: откройте песочницу, в которой показан приведённый ниже код. Объясните, что это, по сути, C-функция, записанная синтаксисом Rust, которая берёт элементы из массива. Сгенерируйте LLVM IR кнопкой Show LLVM IR. Выделите getelementptr inbounds i32, ptr %array, i64 %offset.

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[unsafe(no_mangle)]
pub unsafe fn get(array: *const i32, offset: isize) -> i32 {
    unsafe { *array.offset(offset) }
}

Ожидаемый вывод (строка для выделения начинается с `%_3):

define noundef i32 @get(ptr noundef readonly captures(none) %array, i64 noundef %offset) unnamed_addr #0 {
start:
  %_3 = getelementptr inbounds i32, ptr %array, i64 %offset
  %_0 = load i32, ptr %_3, align 4, !noundef !3
  ret i32 %_0
}

Границы: вы верно заметили, что создание указателя за пределами массива (сверх правила «один за концом») — это неопределённое поведение даже без разыменования, из-за предположений LLVM о inbounds.

Пример геттера

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Возвращает элемент с индексом `index` из `arr`
unsafe fn get(arr: *const i32, index: usize) -> i32 {
    unsafe { *arr.add(index) }
}

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

«Вероятно, вы заметите сильную связь между предусловиями безопасности и правилами безопасного Rust».

Спросите: «Каковы предусловия безопасности get?»

  • Указатель arr не равен null, хорошо выровнен и указывает на массив i32
  • index находится в границах массива

Добавьте комментарии о безопасности:

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Возвращает элемент с индексом `index` из `arr`
///
/// # Safety
///
/// - `arr` не равен null, корректно выровнен и указывает на корректное `i32`
/// - `index` находится в границах массива
unsafe fn get(arr: *const i32, index: usize) -> i32 {
    // SAFETY: вызывающий код гарантирует, что index находится в границах
    unsafe { *arr.add(index) }
}

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

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

debug_assert!(!arr.is_null());
debug_assert_eq!(arr as usize % std::mem::align_of::<i32>(), 0);

Семантические предусловия

Пример: u8 в bool

Определение предусловий

Где найти предусловия безопасности?

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let b: *mut i32 = std::ptr::null_mut();
    println!("{:?}", b.as_mut());
}
This slide and its sub-slides should take about 10 minutes.

Попробуйте скомпилировать программу, чтобы получить ошибку компилятора (“error[E0133]: call to unsafe function …”).

Спросите: «Где бы вы стали искать предусловия функции? Здесь нужно понять, когда безопасно преобразовать указатель, в том числе нулевой, в изменяемую ссылку».

Где искать:

  • Документация API функции, особенно раздел о безопасности (Safety)
  • Исходный код и его внутренние комментарии о безопасности
  • Документация модуля
  • Справочник Rust

Посмотрите документацию метода as_mut.

Обратите внимание на раздел Safety.

Safety

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

Перейдите по ссылке «convertible to a reference» к разделу «Pointer to reference conversion».

Найдите правила преобразования указателя в ссылку, то есть то, является ли он «dereferenceable».

Обдумайте, к чему приводит этот фрагмент (Rust 1.90.0): «You must enforce Rust’s aliasing rules. The exact aliasing rules are not decided yet, …»

Пример: ссылки

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let mut boxed = Box::new(123);
    let a: *mut i32 = &mut *boxed as *mut i32;
    let b: *mut i32 = std::ptr::null_mut();

    println!("{:?}", *a);
    println!("{:?}", b.as_mut());
}

Убедитесь, что синтаксис понятен

  • Тип Box<i32> — это ссылка на целое число в куче, которым владеет box.

  • Тип *mut i32 — так называемый сырой указатель на целое число, владение которым компилятор не знает. Программисты должны сами обеспечивать соблюдение правил без помощи компилятора.

    • Примечание: сырые указатели не передают Rust информацию о владении. Указатель может семантически владеть данными или семантически заимствовать их, но эта информация существует только в голове программиста.
  • Выражение &mut *boxed as *mut _:

    • *boxed — это …
    • &mut *boxed — это …
    • наконец, as *mut i32 приводит ссылку к указателю.
  • Ссылки, такие как &mut i32, «заимствуют» то, на что указывают. Это система владения Rust.

Убедитесь, что владение понятно

  • Пройдите по коду:

    • (Строка 3) Создаёт сырой указатель на 123: разыменовывает box, создаёт новую ссылку и приводит её к указателю.
    • (Строка 4) Создаёт сырой указатель со значением NULL
    • (Строка 7) Преобразует сырой указатель в Option с помощью .as_mut()
  • Обратите внимание, что указатели в Rust могут быть нулевыми (в отличие от ссылок).

  • Скомпилируйте, чтобы увидеть сообщения об ошибках.

  • Обсудите

    • (Строка 6) println!("{:?}", *a);
      • Префиксная звёздочка разыменовывает сырой указатель.
      • Это явная операция. Тогда как обычные ссылки чаще всего разыменовываются неявно благодаря трейту Deref. Это называется «auto-deref».
      • Разыменование сырого указателя — небезопасная операция.
      • Требует небезопасного блока.
    • (Строка 7) println!("{:?}", b.as_mut());
      • as_mut() — небезопасная функция.
      • Вызов небезопасной функции требует небезопасного блока.
  • Продемонстрируйте: исправьте код (добавьте небезопасные блоки) и снова скомпилируйте, чтобы показать работающую программу.

  • Продемонстрируйте: замените as *mut i32 на as *mut _ и покажите, что код компилируется.

    • В приведении можно частично опустить целевой тип. Компилятор Rust знает, что источник приведения — &mut i32. Такой тип ссылки можно преобразовать только в один тип указателя: *mut i32.
  • Добавьте комментарии о безопасности:

    • Мы говорили, что небезопасный код отмечает переход ответственности от компилятора к программисту.
    • Как показать, что мы подумали о своей необычной ответственности, когда писали небезопасный код? Комментарии о безопасности.
    • Комментарии о безопасности объясняют, почему небезопасный код корректен.
    • Без комментария о безопасности небезопасный код не является безопасным.
  • Обсудите: использовать один большой небезопасный блок или два меньших:

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

Предлагаемое решение

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let mut boxed = Box::new(123);
    let a: *mut i32 = &mut *boxed as *mut i32;
    let b: *mut i32 = std::ptr::null_mut();

    // SAFETY: `a` — ненулевой указатель на i32, он инициализирован и всё ещё
    // выделен.
    println!("{:?}", unsafe { *a });

    // SAFETY: `b` — нулевой указатель, который `as_mut()` преобразует в `None`.
    println!("{:?}", unsafe { b.as_mut() });
}

Определение собственных предусловий

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

Пример: тип ASCII

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Текст, который гарантированно закодирован в 7-битном ASCII.
pub struct Ascii<'a>(&'a mut [u8]);

impl<'a> Ascii<'a> {
    pub fn new(bytes: &'a mut [u8]) -> Option<Self> {
        bytes.iter().all(|&b| b.is_ascii()).then(|| Ascii(bytes))
    }

    /// Создаёт новый `Ascii` из среза байтов без проверки
    /// ASCII-корректности.
    ///
    /// # Safety
    ///
    /// Передача байтов, не относящихся к ASCII, приводит к неопределённому поведению.
    pub unsafe fn new_unchecked(bytes: &'a mut [u8]) -> Self {
        Ascii(bytes)
    }
}

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

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

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

unsafe fn new_unchecked(bytes: &mut [u8]) -> Self {
    debug_assert!(bytes.iter().all(|&b| b.is_ascii()))
    Ascii(bytes)
}

Правила игры

«Мы видели на курсе много примеров кода с проблемами, но нам не хватает единой терминологии».

«Цель следующего раздела — ввести термины, которые описывают многие понятия, о которых мы думали».

  • неопределённое поведение
  • корректный (sound)
  • некорректный (unsound)

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

«Общая цель — выработать ментальную модель того, что такое корректность (soundness), и убедиться, что код на Rust с unsafe остаётся корректным».

Rust является корректным (sound)

  • Корректность (soundness) лежит в основе Rust
  • Soundness ≈ невозможность вызвать проблемы с безопасностью памяти
  • У корректных функций есть общие «формы»
This slide should take about 5 minutes.

«Фундаментальный принцип кода на Rust — он корректен».

«Формальное определение soundness мы дадим чуть позже. А пока считайте корректным код, который не может вызвать проблемы с безопасностью памяти».

«Корректный код состоит из корректных функций и корректных операций».

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

У корректных функций есть общие формы.

О них мы сейчас и поговорим.

«Начнём с реализации на безопасном Rust, а затем посмотрим, что может произойти, если вводить unsafe в разные части».

Копирование памяти — введение

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Читает байты из `source` и записывает их в `dest`
pub fn copy(dest: &mut [u8], source: &[u8]) { ... }
This slide and its sub-slides should take about 28 minutes.

«Вот наш исходный прототип функции».

«copy принимает два среза в качестве аргументов. dest (назначение) изменяемый, а source — нет».

«Посмотрим на формы корректного кода на Rust».

Безопасный Rust

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub fn copy(dest: &mut [u8], source: &[u8]) {
    for (dest, src) in dest.iter_mut().zip(source) {
        *dest = *src;
    }
}

fn main() {
    let a = &[114, 117, 115, 116];
    let b = &mut [82, 85, 83, 84];

    println!("{}", String::from_utf8_lossy(b));
    copy(b, a);
    println!("{}", String::from_utf8_lossy(b));
}

«В реализации используется только безопасный Rust».

Что из этого можно вынести?

«Функция copy, реализованная на безопасном Rust, не может вызвать проблемы с безопасностью памяти. Это справедливо для всех возможных входных аргументов».

«Например, используя итераторы Rust, мы можем гарантировать, что никогда не получим ошибок, связанных с прямой работой с указателями, например не понадобятся проверки на null или на границы».

Спросите: «Можете назвать ещё какие-нибудь?»

  • Нет проблем с псевдонимами
  • Висящие указатели невозможны
  • Выравнивание будет корректным
  • Нельзя случайно прочитать неинициализированную память

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

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

«Это не значит, что copy всегда сделает то, чего хочет вызывающий код. Если в срезе dest недостаточно места, данные не будут скопированы».

Инкапсулированный небезопасный Rust

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub fn copy(dest: &mut [u8], source: &[u8]) {
    let len = dest.len().min(source.len());
    let mut i = 0;
    while i < len {
        // SAFETY: `i` должен находиться в границах, так как получен из source.len()
        let new = unsafe { source.get_unchecked(i) };

        // SAFETY: `i` должен находиться в границах, так как получен из dest.len()
        let old = unsafe { dest.get_unchecked_mut(i) };

        *old = *new;
        i += 1;
    }
}

fn main() {
    let a = &[114, 117, 115, 116];
    let b = &mut [82, 85, 83, 84];

    println!("{}", String::from_utf8_lossy(b));
    copy(b, a);
    println!("{}", String::from_utf8_lossy(b));
}

«Здесь у нас безопасная функция, которая инкапсулирует внутренние небезопасные блоки».

«Эта реализация обходится без итераторов. Вместо этого автор реализации обращается к памяти вручную».

«Это корректно?» «Есть ли здесь проблемы?»

«Кто отвечает за обеспечение этой корректности? Автор функции».

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

Открытый небезопасный Rust

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub fn copy(dest: &mut [u8], source: *const u8) {
    let source = {
        let mut len = 0;

        let mut end = source;
        while unsafe { *end != 0 } {
            len += 1;
            end = unsafe { end.add(1) };
        }

        unsafe { std::slice::from_raw_parts(source, len + 1) }
    };

    for (dest, src) in dest.iter_mut().zip(source) {
        *dest = *src;
    }
}

fn main() {
    let a = [114, 117, 115, 116].as_ptr();
    let b = &mut [82, 85, 83, 84, 0];

    println!("{}", String::from_utf8_lossy(b));
    copy(b, a);
    println!("{}", String::from_utf8_lossy(b));
}

Функциональность копирования байтов из одного места в другое остаётся прежней.

«Однако теперь нужно вручную создать срез. Для этого сначала надо найти конец данных».

«Поскольку мы работаем с текстом, используем соглашение C о строках с завершающим нулём».

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

«Некорректная функция может всё равно правильно работать на каких-то входных данных. То, что ваши тесты проходят, ещё не значит, что функция корректна».

«Кто-нибудь видит проблемы?»

  • Читаемость: код трудно быстро просмотреть
  • Указатель source может быть нулевым
  • Указатель source может быть висящим, то есть указывать на освобождённую или неинициализированную память
  • source может не заканчиваться нулевым байтом

«Допустим, мы не можем менять сигнатуру функции. Какие улучшения кода можно сделать, чтобы устранить эти проблемы?»

  • Нулевой указатель: добавьте проверку на null с ранним возвратом (if source.is_null() { return; })
  • Читаемость: используйте проверенную библиотеку, а не реализуйте поиск первого нулевого байта сами

«Однако некоторые требования безопасности невозможно проверить защитным кодом, а именно:»

  • висящий указатель
  • отсутствие завершающего нулевого байта

«Как сделать эту функцию корректной?»

  • Либо
    • Измените тип входного аргумента source на что-то с известной длиной, то есть используйте срез, как в предыдущем примере.
  • Или
    • Пометьте функцию как unsafe
    • Задокументируйте предусловия безопасности

Задокументированные предусловия безопасности

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// ...
///
/// # Safety
///
/// Эта функция легко может вызвать неопределённое поведение. Убедитесь, что:
///
///  - указатель `source` не равен null и не висящий
///  - данные `source` заканчиваются нулевым байтом внутри своего выделения памяти
///  - данные `source` не освобождены (инварианты времени жизни сохраняются)
///  - данные `source` содержат меньше `isize::MAX` байт
pub unsafe fn copy(dest: &mut [u8], source: *const u8) {
    let source = {
        let mut len = 0;

        let mut end = source;
        // SAFETY: вызывающий код передал ненулевой указатель
        while unsafe { *end != 0 } {
            len += 1;
            // SAFETY: вызывающий код передал данные длиной < isize:MAX
            end = unsafe { end.add(1) };
        }

        // SAFETY: вызывающий код соблюдает требования к времени жизни и псевдонимам
        unsafe { std::slice::from_raw_parts(source, len + 1) }
    };

    for (dest, src) in dest.iter_mut().zip(source) {
        *dest = *src;
    }
}

fn main() {
    let a = [114, 117, 115, 116].as_ptr();
    let b = &mut [82, 85, 83, 84, 0];

    println!("{}", String::from_utf8_lossy(b));
    unsafe {
        copy(b, a);
    }
    println!("{}", String::from_utf8_lossy(b));
}

Изменения по сравнению с предыдущими итерациями:

  • copy помечена как unsafe
  • Предусловия безопасности задокументированы
  • встроенные комментарии о безопасности

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

Нужны исправления в main.

  • a не удовлетворяет одному из предусловий copy (данные source должны заканчиваться нулевым байтом внутри своего выделения памяти)
  • Нужен комментарий SAFETY

Ложная тревога

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub unsafe fn copy(dest: &mut [u8], source: &[u8]) {
    for (dest, src) in dest.iter_mut().zip(source) {
        *dest = *src;
    }
}

fn main() {
    let a = &[114, 117, 115, 116];
    let b = &mut [82, 85, 83, 84];

    println!("{}", String::from_utf8_lossy(b));
    unsafe { copy(b, a) };
    println!("{}", String::from_utf8_lossy(b));
}

«Также можно создавать так называемые функции «crying wolf» («кричащие о волке»)».

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

3 формы корректного Rust

  • Функции, написанные только на безопасном Rust
  • Функции, содержащие небезопасные блоки, которые невозможно использовать неправильно
  • Небезопасные функции, предусловия безопасности которых задокументированы
This slide should take about 5 minutes.
  • Мы хотим писать корректный код.
  • Корректный код может иметь только следующие формы:
    • безопасные функции, не содержащие небезопасных блоков
    • безопасные функции, полностью инкапсулирующие небезопасные блоки, то есть вызывающему коду не нужно о них знать
    • небезопасные функции, которые содержат небезопасные блоки, но не инкапсулируют их и перекладывают бремя доказательства на вызывающий код
  • Бремя доказательства
    • безопасные функции только на безопасном Rust → компилятор
    • безопасные функции с небезопасными блоками → автор функции
    • небезопасные функции → вызывающий код функции

Доказательство корректности

Корректность

Корректная функция — это функция, которая не может вызвать UB, если выполнены её предусловия безопасности.

  • Прочитайте определение корректных функций.

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

  • Переведите на неформальный язык. Корректность означает, что функция «хорошая» и играет по правилам. Она документирует свои предусловия безопасности, и когда вызывающий код их выполняет, функция ведёт себя правильно (без UB).

Доказательство корректности (часть 2)

Корректная функция — это функция, которая не может вызвать UB, если выполнены её предусловия безопасности.

Следствие: все функции, написанные на чистом безопасном Rust, корректны.

Доказательство:

  • Код на безопасном Rust не имеет предусловий безопасности.

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

  • Код на безопасном Rust не может вызвать UB.

Ч.т.д.

  • Прочитайте следствие.

  • Объясните доказательство.

  • Переведите на неформальный язык: весь безопасный код на Rust «хороший». У него нет предусловий безопасности, о которых должен думать программист; он всегда играет по правилам и никогда не вызывает UB.

Некорректность

Корректная функция — это функция, которая не может вызвать UB, если выполнены её предусловия безопасности.

Некорректная функция может вызвать UB, даже если вы выполнили задокументированные предусловия безопасности.

Некорректный код — плохой.

  • Прочитайте определение некорректных функций.

  • Переведите на неформальный язык: некорректный код не «хороший». Нет, это преуменьшение. Некорректный код — ПЛОХОЙ. Даже если вы играете по задокументированным правилам, некорректный код всё равно может вызвать UB!

  • Мы не хотим никакого некорректного кода в наших репозиториях.

  • Поиск некорректного кода — главная цель ревью кода.

Жизненный цикл памяти

Память проходит через разные фазы по мере создания и уничтожения объектов (значений).

Состояние памятиМожно читать из безопасного Rust?
ДоступнаНет
ВыделенаНет
ИнициализированаДа

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

Когда память доступна, операционная система предоставила её нашей программе.

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

Когда память инициализирована, из неё безопасно читать.

Инициализация

MaybeUninit

MaybeUninit<T> позволяет Rust ссылаться на неинициализированную память.

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::mem::MaybeUninit;

fn main() {
    let uninit = MaybeUninit::<&i32>::uninit();
    println!("{uninit:?}");
}
This slide and its sub-slides should take about 16 minutes.

«Безопасный Rust не может ссылаться на данные, которые потенциально не инициализированы».

«Однако все данные поступают в программу неинициализированными».

«Поэтому нам нужен мост в системе типов, который позволит памяти переходить между состояниями. Таким типом и является MaybeUninit<T>».

«MaybeUninit<T> очень похож на тип Option<T>, хотя его семантика сильно отличается. Аналог Option::None для MaybeUninit<T> — неинициализированная память, в которую безопасно только записывать».

«Чтение из памяти, которая может быть неинициализированной, крайне опасно».

MaybeUninit и массивы

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::mem::MaybeUninit;
use std::ptr;

fn main() {
    let input = b"RUST";

    let mut buf = [const { MaybeUninit::<u8>::uninit() }; 2048];

    // Инициализируем элементы, записывая значения в память
    for (i, input_byte) in input.iter().enumerate() {
        unsafe {
            let dst = buf.as_mut_ptr().add(i);
            ptr::write((*dst).as_mut_ptr(), *input_byte);
        }
    }

    // Когда часть массива инициализирована, можно
    // использовать unsafe, чтобы выделить её
    let ptr_to_init_subslice = buf.as_ptr() as *const u8;
    let init =
        unsafe { std::slice::from_raw_parts(ptr_to_init_subslice, input.len()) };
    let text = std::str::from_utf8(init).unwrap();
    println!("{text}");

    // Инициализированные элементы нужно освобождать вручную
    for element in &mut buf[0..input.len()] {
        unsafe {
            element.assume_init_drop();
        }
    }
}

Чтобы создать массив неинициализированной памяти, можно использовать конструктор ::uninit() в контексте const.

Инициализируйте значения обычным способом через ptr::write.

.assume_init() для массивов работает не так просто. Он требует, чтобы каждое значение было инициализировано, а это может не выполняться при повторном использовании буфера. В этом примере указатель используется, чтобы выделить инициализированные байты и создать строковый срез.

При создании подсреза частично инициализированного массива будьте внимательны с владением и правильной реализацией drop. Напоминание: MaybeUninit<T> не вызывает drop для своего T.

MaybeUninit<[u8;2048]> отличается от [MaybeUninit::<u8>; 2048]. Это разница между массивом неинициализированной памяти и массивом, который содержит неинициализированные элементы.

  • MaybeUninit<[u8;2048]> — «всё или ничего». Либо вы полностью инициализируете весь массив и затем вызываете assume_init, либо должны оставить его как MaybeUninit<[u8; 2048]> и не обращаться к нему как к [u8; 2048].
  • [MaybeUninit<u8>; 2048] позволяет инициализировать элементы по одному, затем взять подсрез только из инициализированного префикса и обращаться с ним как с [u8] через std::slice::from_raw_parts.
  • slice_assume_init_ref безопасен, только если каждый элемент среза инициализирован. В этом примере мы передаём &buf[..input.len()] только после записи ровно этих байтов.
  • Если T требует drop, для инициализированных элементов нужно вручную вызвать assume_init_drop(). Если это пропустить, возникнет утечка памяти. Однако вызов этого метода для неинициализированного элемента — неопределённое поведение.

MaybeUninit::zeroed()

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::mem::{MaybeUninit, transmute};

fn main() {
    let mut x = [const { MaybeUninit::<u32>::zeroed() }; 10];

    x[6].write(7);

    // SAFETY: все значения `x` были записаны
    let x: [u32; 10] = unsafe { transmute(x) };
    println!("{x:?}")
}

«MaybeUninit::zeroed() — альтернативный конструктор для MaybeUninit::uninit(). Он указывает компилятору заполнить биты T нулями».

Вопрос: «Хотя память была записана, тип остаётся MaybeUninit<T>. Может ли кто-нибудь предположить почему?»

Ответ: значения некоторых типов не могут быть нулевыми (null). Классический случай — ссылки, но это относится и ко многим другим типам. Вспомните целочисленный тип NonZeroUsize и другие типы этого семейства.

MaybeUninit.write() и присваивание

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::mem::MaybeUninit;

fn main() {
    let mut buf = MaybeUninit::<String>::uninit();

    // Инициализация
    buf.write(String::from("Hello, Rust!"));

    // Перезапись
    buf.write(String::from("Hi again"));

    // Присваивание заменяет всё значение MaybeUninit.
    buf = MaybeUninit::new(String::from("Goodbye"));

    // Убеждаемся, что внутреннее значение уничтожено
    let _ = unsafe { buf.assume_init() };
}

Замена внутренних значений может приводить к утечкам памяти, потому что семантика drop отличается от большинства типов. MaybeUninit<T> не вызывает деструктор для своего T.

MaybeUninit::write() использует ptr::write: он инициализирует память на месте, не читая и не уничтожая старое содержимое. Это именно то, что нужно, когда память может быть неинициализированной, но это также означает утечку, если там уже было живое значение.

Присваивание, например buf = MaybeUninit::new(value), заменяет весь MaybeUninit. Старое значение MaybeUninit перемещается и затем уничтожается, но у MaybeUninit нет деструктора для T, поэтому внутреннее значение не уничтожается. Если в старом слоте был инициализированный объект, он утекает, как и при write().

Если нужно обычное поведение drop, сообщите Rust, что значение инициализировано, с помощью assume_init или одного из связанных методов.

Как инициализировать память

Шаги:

  1. Создать MaybeUninit<T>
  2. Записать в него значение
  3. Сообщить Rust, что память инициализирована
// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::mem::MaybeUninit;

fn main() {
    // Шаг 1: создаём MaybeUninit
    let mut uninit = MaybeUninit::uninit();

    // Шаг 2: записываем корректное значение в память
    uninit.write(1);

    // Шаг 3: сообщаем системе типов, что область памяти корректна
    let init = unsafe { uninit.assume_init() };

    println!("{init}");
}
This slide should take about 8 minutes.

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

  1. Создайте MaybeUninit<T>. Конструктор ::uninit() — самый универсальный, но есть и другие, которые тоже выполняют запись.

  2. Запишите значение типа T. Обратите внимание, что это доступно из безопасного Rust. Оставаться в безопасном Rust полезно, потому что вы должны убедиться, что записываемое значение корректно.

  3. Подтвердите системе типов, что память теперь инициализирована, с помощью метода .assume_init().

Частичная инициализация

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::mem::MaybeUninit;

fn main() {
    // let mut buf = [0u8; 2048];
    let mut buf = [const { MaybeUninit::<u8>::uninit() }; 2048];

    let external_data = b"Hello, Rust!";
    let len = external_data.len();

    for (dest, src) in buf.iter_mut().zip(external_data) {
        dest.write(*src);
    }

    // SAFETY: мы инициализировали ровно 'len' байт `buf` текстом в UTF-8
    let text: &str = unsafe {
        let ptr: *const u8 = buf.as_ptr().cast::<u8>();
        let init: &[u8] = std::slice::from_raw_parts(ptr, len);
        std::str::from_utf8_unchecked(init)
    };

    println!("{text}");
}

Этот код имитирует получение данных из какого-то внешнего источника.

При чтении байтов из внешнего источника в буфер обычно неизвестно, сколько байтов будет получено. Использование MaybeUninit<T> позволяет выделить буфер один раз, не платя за лишний проход инициализации.

Если бы мы создали массив стандартным синтаксисом (buf = [0u8; 2048]), весь буфер заполнился бы нулями. MaybeUninit<T> говорит компилятору зарезервировать место, но пока не трогать память.

Вопрос: какая часть фрагмента кода выполняет роль, похожую на .assume_init()? Ответ: приведение указателя и неявное чтение.

Нельзя вызвать assume_init() для всего массива: это было бы некорректно, потому что большинство элементов остаются неинициализированными. Вместо этого мы приводим указатель из *const MaybeUninit<u8> к *const u8 и строим срез, охватывающий только инициализированную часть.

Закрепление (pinning)

В этом разделе курса рассматривается:

  • Что такое «pinning»
  • Зачем он нужен
  • Как Rust его реализует
  • Как он взаимодействует с unsafe и FFI

План

Этот раздел должен занять около 1 часа 20 минут. В нём есть:

СлайдДлительность
Что такое закрепление5 минут
Определение Pin5 минут
PhantomPinned5 минут
Пример самоссылочного буфера50 минут
Pin и Drop15 минут

«Pinning, то есть удержание адреса памяти значения в фиксированном месте, — одна из самых сложных концепций в Rust».

«Обычно его видят только в асинхронном коде, то есть в poll(self: Pin<&mut Self>), но применимость pinning шире».

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

Заметный связанный с этим сценарий — FFI с C++. Rust должен считать, что любой объект C++ со ссылкой может оказаться самоссылающейся структурой данных.

«Чтобы лучше понять этот конфликт, сначала нужно хорошо разобраться в семантике перемещения (move) в Rust».

Что такое закрепление

  • Закреплённый (pinned) тип не может менять свой адрес в памяти (перемещаться)
  • Значение, на которое указывает указатель, не может быть перемещено безопасным кодом

Pin<Ptr> использует систему владения, чтобы контролировать доступ к закреплённому значению. Вместо изменения языка для обеспечения pinning используется система владения Rust. Pin владеет своим содержимым, и ничто в его безопасном API не вызывает перемещения.

Это объясняется в

This slide should take about 5 minutes.

Концептуально pinning запрещает поведение перемещения по умолчанию.

Может показаться, что это изменение самого языка.

Однако обёртка Pin на самом деле не меняет в языке ничего принципиального.

Pin не предоставляет безопасных API, которые позволили бы перемещение. Поэтому он может предотвращать побитовое копирование.

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

В документации Pin используется термин «pointer types».

Термин «pointer type» гораздо шире, чем примитивный тип указателя в языке.

«Pointer type» — это любой тип, реализующий Deref, у которого целевой тип реализует Unpin.

Замечание о стиле Rust: это ограничение трейта проверяется через ограничения трейтов на конструкторе ::new(), а не на самом типе.

Что такое перемещение в Rust

Всегда побитовое копирование, даже для типов, которые не реализуют Copy:

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug, Default)]
pub struct DynamicBuffer {
    data: Vec<u8>,
    position: usize,
};

pub fn move_and_inspect(x: DynamicBuffer) { println!("{x:?}"); }

pub fn main() {
   let a = DynamicBuffer::default();
   let mut b = a;
   b.data.push(b'R');
   b.data.push(b'U');
   b.data.push(b'S');
   b.data.push(b'T');
   move_and_inspect(b);
}

Сгенерированный LLVM IR для вызова move_and_expect():

call void @llvm.memcpy.p0.p0.i64(ptr align 8 %_12, ptr align 8 %b, i64 32, i1 false)
invoke void @move_and_inspect(ptr align 8 %_12)
  • memcpy из переменной %b в %_12
  • Вызов move_and_inspect с %_12 (копией)

Обратите внимание, что DynamicBuffer не реализует Copy.

Следствие: адрес памяти значения не стабилен.

Чтобы показать перемещение как побитовое копирование, откройте код в песочнице и посмотрите на вывод, либо откройте Compiler Explorer.

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

Compiler Explorer полезен для обсуждения сгенерированного ассемблера. Установите курсор на вывод ассемблера в функции main, строки 128–136 (они должны подсвечиваться розовым).

Соответствующий сгенерированный вывод move_and_inspect:

mov     rax, qword ptr [rsp + 16]
mov     qword ptr [rsp + 48], rax    
mov     rax, qword ptr [rsp + 24]
mov     qword ptr [rsp + 56], rax
movups  xmm0, xmmword ptr [rsp]
movaps  xmmword ptr [rsp + 32], xmm0
lea     rdi, [rsp + 32]
call    qword ptr [rip + move_and_inspect@GOTPCREL]

Определение Pin

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[repr(transparent)]
pub struct Pin<Ptr> {
    pointer: Ptr,
}

impl<Ptr: Deref<Target: Unpin>> Pin<Ptr> {
    pub fn new(pointer: Ptr) -> Pin<Ptr> { ... }
}

impl<Ptr: Deref> Pin<Ptr> {
    pub unsafe fn new_unchecked(pointer: Ptr) -> Pin<Ptr> { ... }
}
This slide should take about 5 minutes.

Pin — минимальная обёртка вокруг pointer type, то есть типа, который реализует Deref.

Однако Pin::new() принимает только типы, которые разыменовываются в цель, реализующую Unpin (Deref<Target: Unpin>). Это позволяет Pin полагаться на систему типов для обеспечения своих гарантий.

Типы, которые не реализуют Unpin, то есть требующие pinning, должны создавать Pin через небезопасный Pin::new_unchecked().

Замечание: в отличие от других пар методов new()/new_unchecked(), new не выполняет проверок во время выполнения. Проверка — это проверка на этапе компиляции без накладных расходов.

Почему Pin сложно использовать

  • Pin<Ptr> — «всего лишь» тип, определённый в стандартной библиотеке
  • Это удовлетворило потребности его изначальной аудитории, создателей асинхронных рантаймов, без необходимости расширять ядро языка
  • Эта аудитория могла смириться с некоторыми недостатками эргономики, поскольку пользователи async редко работают с Pin напрямую

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

«Pin<Ptr> предложил Rust-проекту более простую реализацию, чем альтернативы».

«Pin был спроектирован в первую очередь для примерно ста человек в мире, которые пишут асинхронные рантаймы. Команда Rust выбрала более простой (для компилятора), но менее эргономичный дизайн».

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

Трейт Unpin

  • Трейт Unpin позволяет типам свободно перемещаться, даже если они обёрнуты в Pin
  • Большинство типов реализуют Unpin, потому что это «auto trait»
  • Поведение auto trait можно изменить:
    • Типы !Unpin никогда не должны перемещаться
    • Типы с полем PhantomPinned по умолчанию не реализуют Unpin

Объясните, что если тип реализует Unpin, поведение pinning у Pin<Ptr> не применяется. Значение свободно перемещается.

Объясните, что почти все типы реализуют Unpin; это делает компилятор автоматически.

Типы, реализующие Unpin, как бы говорят: «Обещаю, что у меня нет самоссылок, поэтому перемещать меня всегда безопасно».

Спросите: какие типы могут быть !Unpin?

  • Фьючерсы, сгенерированные компилятором
  • Типы с полем PhantomPinned
  • Некоторые типы, оборачивающие объекты C++

Типы !Unpin нельзя переместить после закрепления

PhantomPinned

Определение

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub struct PhantomPinned;

impl !Unpin for PhantomPinned {}

Использование

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub struct DynamicBuffer {
    data: Vec<u8>,
    cursor: std::ptr::NonNull<u8>,
    _pin: std::marker::PhantomPinned,
}
This slide should take about 5 minutes.

PhantomPinned — маркерный тип.

Если тип содержит PhantomPinned, по умолчанию он не реализует Unpin.

Это обеспечивает pinning, когда DynamicBuffer обёрнут в Pin.

Пример самоссылочного буфера

«Самоссылающийся буфер» — это тип, который хранит ссылку на одно из своих полей:

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

pub struct SelfReferentialBuffer {
    data: [u8; 1024],
    cursor: *mut u8,
}

Такая структура нетипична для Rust, потому что нет способа обновить адрес курсора при перемещении экземпляров SelfReferentialBuffer.

Однако такая конструкция более естественна в языках со сборкой мусора, а также в C++, который позволяет пользователям определять собственное поведение при перемещении и копировании.

План

Этот раздел должен занять около 1 часа 20 минут. В нём есть:

СлайдДлительность
Что такое закрепление5 минут
Определение Pin5 минут
PhantomPinned5 минут
Пример самоссылочного буфера50 минут
Pin и Drop15 минут

Смоделировано на C++

#include <cstddef>
#include <cstring>

class SelfReferentialBuffer {
    std::byte data[1024];
    std::byte* cursor = data;
    
public:
    SelfReferentialBuffer(SelfReferentialBuffer&& other) 
        : cursor{data + (other.cursor - other.data)}
    {
        std::memcpy(data, other.data, 1024);
    }
};

Изучите на Compiler Explorer

SelfReferentialBuffer содержит два члена: data — килобайт памяти, а cursor — указатель внутрь первого.

Его конструктор перемещения гарантирует, что cursor обновляется до нового адреса в памяти.

Этот тип нелегко выразить в Rust.

Моделирование на Rust

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

/// Сырые указатели
pub struct SelfReferentialBuffer {
    data: [u8; 1024],
    cursor: *mut u8,
}

/// Целочисленные смещения
pub struct SelfReferentialBuffer {
    data: [u8; 1024],
    cursor: usize,
}

/// Pinning (закрепление)
pub struct SelfReferentialBuffer {
    data: [u8; 1024],
    cursor: *mut u8,
    _pin: std::marker::PhantomPinned,
}

Исходное определение класса на C++ для справки

class SelfReferentialBuffer {
    char data[1024];
    char* cursor;
};

Следующие слайды показывают три подхода к созданию типа на Rust с той же семантикой, что и исходный C++.

  • Сырые указатели: очень близко повторяют C++, но работа с получившимся типом крайне опасна
  • Хранение целочисленных смещений: более естественно для Rust, но ссылки приходится создавать вручную
  • Pinning: позволяет использовать сырые указатели с меньшим количеством блоков unsafe

С сырым указателем

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug)]
pub struct SelfReferentialBuffer {
    data: [u8; 1024],
    cursor: *mut u8,
}

impl SelfReferentialBuffer {
    pub fn new() -> Self {
        let mut buffer =
            SelfReferentialBuffer { data: [0; 1024], cursor: std::ptr::null_mut() };

        buffer.update_cursor();
        buffer
    }

    // Опасно: вызывать после каждого перемещения
    pub fn update_cursor(&mut self) {
        self.cursor = self.data.as_mut_ptr();
    }

    pub fn read(&self, n_bytes: usize) -> &[u8] {
        unsafe {
            let start = self.data.as_ptr();
            let end = start.add(1024);
            let cursor = self.cursor as *const u8;

            assert!((start..=end).contains(&cursor), "cursor is out of bounds");

            let available = end.offset_from(cursor) as usize;
            let len = n_bytes.min(available);
            std::slice::from_raw_parts(cursor, len)
        }
    }

    pub fn write(&mut self, bytes: &[u8]) {
        unsafe {
            let start = self.data.as_mut_ptr();
            let end = start.add(1024);

            assert!((start..=end).contains(&self.cursor), "cursor is out of bounds");
            let available = end.offset_from(self.cursor) as usize;
            let len = bytes.len().min(available);

            std::ptr::copy_nonoverlapping(bytes.as_ptr(), self.cursor, len);
            self.cursor = self.cursor.add(len);
        }
    }
}

Не тратьте здесь много времени.

Тезисы для обсуждения:

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

Вопросы:

Вопрос: должны ли методы read() и write() быть помечены как unsafe? Ответ: да, потому что self.cursor будет нулевым указателем, пока в него что-то не записано.

Со смещением (Offset)

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[derive(Debug)]
pub struct SelfReferentialBuffer {
    data: [u8; 1024],
    position: usize,
}

impl SelfReferentialBuffer {
    pub fn new() -> Self {
        SelfReferentialBuffer { data: [0; 1024], position: 0 }
    }

    pub fn read(&self, n_bytes: usize) -> &[u8] {
        let available = self.data.len().saturating_sub(self.position);
        let len = n_bytes.min(available);
        &self.data[self.position..self.position + len]
    }

    pub fn write(&mut self, bytes: &[u8]) {
        let available = self.data.len().saturating_sub(self.position);
        let len = bytes.len().min(available);
        self.data[self.position..self.position + len].copy_from_slice(&bytes[..len]);
        self.position += len;
    }
}

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

С Pin<Ptr>

Pinning позволяет программистам на Rust создавать тип, гораздо более похожий на классы C++.

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::marker::PhantomPinned;
use std::pin::Pin;

/// Самоссылающийся буфер, который нельзя переместить.
#[derive(Debug)]
pub struct SelfReferentialBuffer {
    data: [u8; 1024],
    cursor: *mut u8,
    _pin: PhantomPinned,
}

impl SelfReferentialBuffer {
    pub fn new() -> Pin<Box<Self>> {
        let buffer = SelfReferentialBuffer {
            data: [0; 1024],
            cursor: std::ptr::null_mut(),
            _pin: PhantomPinned,
        };
        let mut pinned = Box::pin(buffer);

        unsafe {
            let mut_ref = Pin::get_unchecked_mut(pinned.as_mut());
            mut_ref.cursor = mut_ref.data.as_mut_ptr();
        }

        pinned
    }

    pub fn read(&self, n_bytes: usize) -> &[u8] {
        unsafe {
            let start = self.data.as_ptr();
            let end = start.add(self.data.len());
            let cursor = self.cursor as *const u8;

            assert!((start..=end).contains(&cursor), "cursor is out of bounds");

            let offset = cursor.offset_from(start) as usize;
            let available = self.data.len().saturating_sub(offset);
            let len = n_bytes.min(available);

            &self.data[offset..offset + len]
        }
    }

    pub fn write(mut self: Pin<&mut Self>, bytes: &[u8]) {
        let this = unsafe { self.as_mut().get_unchecked_mut() };
        unsafe {
            let start = this.data.as_mut_ptr();
            let end = start.add(1024);

            assert!((start..=end).contains(&this.cursor), "cursor is out of bounds");
            let available = end.offset_from(this.cursor) as usize;
            let len = bytes.len().min(available);

            std::ptr::copy_nonoverlapping(bytes.as_ptr(), this.cursor, len);
            this.cursor = this.cursor.add(len);
        }
    }
}

Обратите внимание, что сигнатуры функций теперь изменились. Например, ::new() возвращает Pin<Box<Self>> вместо Self. Это приводит к выделению памяти в куче, потому что Pin<Ptr> должен работать с типом указателя, таким как Box.

В ::new() мы используем Pin::get_unchecked_mut(), чтобы получить изменяемую ссылку на буфер после того, как он закреплён. Это unsafe, потому что мы на время нарушаем гарантию pinning, чтобы инициализировать cursor. После этого момента нельзя перемещать SelfReferentialBuffer. Контракт безопасности Pin таков: как только значение закреплено, его адрес в памяти фиксирован до уничтожения.

Pin<Ptr> и Drop

Ключевая сложность закреплённых типов !Unpin — реализация трейта Drop. Метод drop принимает &mut self, что позволяет переместить значение. Однако закреплённые значения перемещать нельзя.

Неправильная реализация Drop

Легко случайно переместить значение внутри drop. Такие операции, как присваивание, ptr::read и mem::replace, могут незаметно нарушить гарантию pinning.

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct SelfRef {
    data: String,
    ptr: *const String,
}

impl Drop for SelfRef {
    fn drop(&mut self) {
        // ПЛОХО: `ptr::read` перемещает `self.data` из `self`.
        // Когда `_dupe` уничтожается в конце функции, происходит двойное освобождение!
        let _dupe = unsafe { std::ptr::read(&self.data) };
    }
}
This slide and its sub-slides should take about 15 minutes. `!Unpin` types can make it difficult to safely implement `Drop`. Implementations must not move pinned values.

Закреплённые типы дают гарантии стабильности памяти. Такие операции, как ptr::read и mem::replace, могут незаметно нарушить эти гарантии, перемещая или дублируя данные и делая недействительными внутренние указатели без ведома системы типов.

В этом методе drop() _dupe — побитовая копия self.data. В конце метода он будет уничтожен вместе с self. Это двойное уничтожение — неопределённое поведение.

Правильная реализация Drop

Чтобы правильно реализовать Drop для типа !Unpin, нужно гарантировать, что значение не перемещается. Распространённый приём — создать вспомогательную функцию, которая работает с Pin<&mut T>.

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::marker::PhantomPinned;
use std::pin::Pin;

struct SelfRef {
    data: String,
    ptr: *const String,
    _pin: PhantomPinned,
}

impl SelfRef {
    fn new(data: impl Into<String>) -> Pin<Box<SelfRef>> {
        let mut this = Box::pin(SelfRef {
            data: data.into(),
            ptr: std::ptr::null(),
            _pin: PhantomPinned,
        });
        let ptr: *const String = &this.data;
        // SAFETY: `this` закреплён до того, как мы создаём самоссылку.
        unsafe {
            Pin::as_mut(&mut this).get_unchecked_mut().ptr = ptr;
        }
        this
    }

    // Эту функцию можно вызвать только для закреплённого `SelfRef`.
    unsafe fn drop_pinned(self: Pin<&mut SelfRef>) {
        // `self` закреплён, поэтому из него нельзя перемещать значение.
        println!("dropping {}", self.data);
    }
}

impl Drop for SelfRef {
    fn drop(&mut self) {
        // Можно безопасно вызвать `drop_pinned`, потому что `drop` — последний момент,
        // когда значение используется. Мы используем `new_unchecked`, потому что знаем,
        // что `self` больше не будет перемещён.
        unsafe {
            SelfRef::drop_pinned(Pin::new_unchecked(self));
        }
    }
}

fn main() {
    let _pinned = SelfRef::new("Hello, ");
} // `Drop` выполняется без перемещения закреплённого значения

Пример: реализация Drop для типов !Unpin

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::cell::RefCell;
use std::marker::PhantomPinned;
use std::mem;
use std::pin::Pin;

thread_local! {
    static BATCH_FOR_PROCESSING: RefCell<Vec<String>> = RefCell::new(Vec::new());
}

#[derive(Debug)]
struct CustomString(String);

#[derive(Debug)]
struct SelfRef {
    data: CustomString,
    ptr: *const CustomString,
    _pin: PhantomPinned,
}

impl SelfRef {
    fn new(data: &str) -> Pin<Box<SelfRef>> {
        let mut boxed = Box::pin(SelfRef {
            data: CustomString(data.to_owned()),
            ptr: std::ptr::null(),
            _pin: PhantomPinned,
        });

        let ptr: *const CustomString = &boxed.data;
        unsafe {
            Pin::get_unchecked_mut(Pin::as_mut(&mut boxed)).ptr = ptr;
        }
        boxed
    }
}

impl Drop for SelfRef {
    fn drop(&mut self) {
        // SAFETY: безопасно, потому что мы читаем байты из String
        let payload = unsafe { std::ptr::read(&self.data) };
        BATCH_FOR_PROCESSING.with(|log| log.borrow_mut().push(payload.0));
    }
}

fn main() {
    let pinned = SelfRef::new("Rust 🦀");
    drop(pinned);

    BATCH_FOR_PROCESSING.with(|batch| {
        println!("Batch: {:?}", batch.borrow());
    });
}

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

Комментарий о безопасности неверен. ptr::read создаёт побитовую копию, оставляя self.data в недопустимом состоянии. self.data будет уничтожен снова в конце метода, что приводит к двойному освобождению.

Попросите слушателей исправить код.

Вариант 0: переработать дизайн

Переработайте систему постобработки так, чтобы она работала без Drop.

Вариант 1: Clone

.clone() — очевидный первый выбор, но он выделяет память.

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

impl Drop for SelfRef {
    fn drop(&mut self) {
        let payload = self.data.0.clone();
        BATCH_FOR_PROCESSING.with(|log| log.borrow_mut().push(payload));
    }
}

Вариант 2: ManuallyDrop

Обёртка CustomString в ManuallyDrop предотвращает второе автоматическое уничтожение в конце реализации Drop.

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

struct SelfRef {
    data: ManuallyDrop<CustomString>,
    ptr: *const CustomString,
    _pin: PhantomPinned,
}

// ...

impl Drop for SelfRef {
    fn drop(&mut self) {
        // SAFETY: self.data
        let payload = unsafe { ManuallyDrop::take(&mut self.data) };
        BATCH_FOR_PROCESSING.with(|log| log.borrow_mut().push(payload.0));
    }
}

FFI

Взаимодействие языков

Идеальный сценарий:

RustC++

Этот раздел курса посвящён взаимодействию Rust с внешними языками через интерфейс внешних функций (FFI), с особым вниманием к взаимодействию с C++.

В идеале пользователи Rust и внешнего языка (в данном случае C++) могли бы напрямую вызывать методы друг друга.

Этот идеальный сценарий очень трудно осуществить:

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


  1. Некоторые поставщики компиляторов C++ обеспечивают стабильность ABI в своём тулчейне. ↩

Стратегии взаимодействия

Прямое совместное использование структур данных и символов очень сложно:

RustC++

FFI через C ABI гораздо осуществимее:

RustCCC++

Другие стратегии:

  • Распределённая система (RPC)
  • Собственный ABI (например, WebAssembly Interface Types)
This slide should take about 5 minutes.

Взаимодействие высокой точности

Идеальный сценарий пока находится в экспериментальной стадии.

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

Взаимодействие низкой точности работает через C API

Типичная стратегия взаимодействия — использовать язык C в качестве интерфейса. C — это «кодек с потерями». Эта стратегия обычно приводит к сложному коду с обеих сторон.

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

Распределённые системы создают издержки во время выполнения.

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

Собственный ABI, например wasm, требует рантайма или значительных затрат на реализацию.

Соображения: безопасность типов

Различия языков

RustCCC++
This slide and its sub-slides should take about 29 minutes.

Использование C как наименьшего общего знаменателя означает потерю большей части богатства возможностей Rust и C++.

Каждое преобразование может привести к потере семантики, накладным расходам во время выполнения и тонким ошибкам.

Различные представления

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn main() {
    let c_repr = b"Hello, C\0";
    let cc_repr = (b"Hello, C++\0", 10u32);
    let rust_repr = (b"Hello, Rust", 11);
}

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

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

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

// Представление C → Rust
unsafe {
    let ptr = c_repr.as_ptr() as *const i8;
    let c: &str = std::ffi::CStr::from_ptr(ptr).to_str().unwrap();
    println!("{c}");
};

// Представление C++ → Rust 
unsafe {
    let ptr = cc_repr.0.as_ptr();
    let bytes = std::slice::from_raw_parts(ptr, cc_repr.1);
    let cc: &str = std::str::from_utf8_unchecked(bytes);
    println!("{cc}");
};

// Представление Rust (байты) → строковый срез
unsafe {
    let ptr = rust_repr.0.as_ptr();
    let bytes = std::slice::from_raw_parts(ptr, rust_repr.1);
    let rust: &str = std::str::from_utf8_unchecked(bytes);
    println!("{rust}");
};

Замечание: в Rust есть строковый литерал с префиксом c. Он добавляет нулевой байт в конец, например c"Rust" == b"Rust\0".

Различная семантика

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::ffi::{CStr, c_char};
use std::time::{SystemTime, SystemTimeError, UNIX_EPOCH};

unsafe extern "C" {
    /// Создаёт отформатированное время по метке времени `t`.
    fn ctime(t: *const libc::time_t) -> *const c_char;
}

fn now_formatted() -> Result<String, SystemTimeError> {
    let now = SystemTime::now().duration_since(UNIX_EPOCH)?;
    let seconds = now.as_secs() as i64;

    // SAFETY: `seconds` генерируется системными часами и не вызовет
    // переполнения
    let ptr = unsafe { ctime(&seconds) };

    // SAFETY: ctime возвращает указатель на предварительно выделенный (ненулевой) буфер
    let ptr = unsafe { CStr::from_ptr(ptr) };

    // SAFETY: ctime использует корректный UTF-8
    let fmt = ptr.to_str().unwrap();

    Ok(fmt.trim_end().to_string())
}

fn main() {
    let t = now_formatted();
    println!("{t:?}");
}

Некоторые конструкции, которые допускают другие языки, невозможно выразить в языке Rust.

Функция ctime изменяет внутренний буфер, общий для всех вызовов. Это нельзя выразить временами жизни Rust.

  • 'static не подходит, потому что семантика другая
  • 'a не подходит, потому что буфер переживает каждый вызов

Rust ↔ C

АспектRustC
ОшибкиResult<T, E>, Option<T>Магические возвращаемые значения, выходные параметры, глобальный errno
Строки&str/String (UTF-8, длина известна)char* с завершающим нулём, кодировка не определена
Нулевые значения (nullability)Явно через Option<T>Любой указатель может быть null
ВладениеАффинные типы, времена жизниСоглашения
Обратные вызовыЗамыкания Fn/FnMut/FnOnceУказатель на функцию + void* userdata
ПаникиРаскрутка стека (или abort)Аварийное завершение (abort)

Ошибки: нужно преобразовывать Result по соглашениям C; легко забыть проверить ошибки на стороне C.

Строки: стоимость преобразования; нулевые байты в строках Rust приводят к обрезанию; проверка UTF-8 при входе.

Нулевые значения: каждый указатель из C нужно проверить, чтобы создать Option<NonNull<T>>, что подразумевает блоки unsafe или затраты во время выполнения.

Владение: время жизни объектов нужно документировать и соблюдать вручную.

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

Паники: паника, пересекающая границу FFI, — неопределённое поведение; её нужно перехватывать на границе с помощью catch_unwind.

C++ ↔ C

АспектCC++
ПерегрузкаВручную / ad hocАвтоматически
Исключения-Раскрутка стека
ДеструкторыРучная очисткаАвтоматически через деструкторы (RAII)
Типы, не являющиеся POD-Объекты с конструкторами, vtable, виртуальными базами
Шаблоны-Генерация кода на этапе компиляции

В C++ есть ряд возможностей, которых нет в C и которые влияют на FFI:

Перегрузка: перегрузки становятся невыразимыми из-за искажения имён (name mangling)

Исключения: их нужно перехватывать на границе FFI и преобразовывать в коды ошибок, поскольку исключение, вырвавшееся из функции extern "C", — неопределённое поведение

Деструкторы: вызывающий код на C не будет запускать деструкторы; нужно открыть явные функции *_destroy()

Типы, не являющиеся POD: через границу FFI нужно передавать непрозрачные указатели, поскольку передача по значению здесь не имеет смысла

Шаблоны: напрямую открыть нельзя; нужно явно инстанцировать и обернуть каждую специализацию

Rust ↔ C++

АспектRustC++
Тривиальная перемещаемость (trivial relocatability)Все перемещения — это memcpyСамоссылающиеся типы; конструкторы перемещения могут иметь побочные эффекты
Безопасность уничтоженияDrop::drop() только в исходном местеДеструктор может выполняться для объектов, из которых уже выполнено перемещение
Безопасность исключенийПаники (abort или unwind)Исключения (unwind)
Стабильность ABIЯвно нестабиленЗависит от поставщика

Даже если бы удалось обойтись взаимодействием через C, некоторые области языков всё равно влияют на FFI:

Тривиальная перемещаемость

На стороне Rust нельзя безопасно перемещать объекты C++; их нужно закреплять (pin) или хранить в куче C++.

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

C++ позволяет пользователям задавать собственную семантику: они могут перегружать оператор присваивания и создавать конструкторы перемещения и копирования.

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

Объекты с той же семантикой невозможно определить в Rust.

Безопасность уничтожения

Семантика объектов C++, из которых выполнено перемещение, не соответствует Rust; нужно не допустить, чтобы Rust «перемещал» типы C++.

Безопасность исключений

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

Обёртка для abs(3)

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

fn abs(x: i32) -> i32;

fn main() {
    let x = -42;
    let abs_x = abs(x);
    println!("{x}, {abs_x}");
}
This slide should take about 15 minutes.

На этом слайде мы задаём шаблон написания обёрток.

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

Обратите внимание, что пока это не работает.

Добавьте блок extern:

#![allow(unused)]
fn main() {
// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

unsafe extern "C" {
    fn abs(x: i32) -> i32;
}
}

Объясните, что многие функции POSIX доступны в Rust, потому что Cargo по умолчанию линкуется со стандартной библиотекой C (libc), которая вводит её символы в область видимости программы.

Покажите man 3 abs в терминале или страницу в вебе.

Объясните, что наша сигнатура функции должна совпадать с её определением: int abs(int j);.

Обновите блок кода, чтобы использовать типы C.

#![allow(unused)]
fn main() {
// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::ffi::c_int;

unsafe extern "C" {
    fn abs(x: c_int) -> c_int;
}
}

Обсудите обоснование: использование ffi::c_int повышает переносимость кода. Когда стандартная библиотека собирается под целевую платформу, платформа может определить разрядность. Согласно стандарту C, c_int может быть определён как i16, а не как гораздо более распространённый i32.

(По желанию) Покажите документацию для c_int, чтобы показать, что это псевдоним типа для i32.

Попробуйте скомпилировать, чтобы получить сообщение «error: extern blocks must be unsafe».

Добавьте ключевое слово unsafe к блоку:

#![allow(unused)]
fn main() {
// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::ffi::c_int;

unsafe extern "C" {
    fn abs(x: c_int) -> c_int;
}
}

Убедитесь, что слушатели понимают значение этого изменения. Теперь мы обязаны соблюдать типобезопасность и другие предусловия безопасности.

Скомпилируйте снова.

Добавьте ключевое слово safe к функции abs:

#![allow(unused)]
fn main() {
// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::ffi::c_int;

unsafe extern "C" {
    safe fn abs(x: c_int) -> c_int;
}
}

Объясните, что safe fn помечает abs как безопасную для вызова без блока unsafe.

Готовая программа для справки:

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::ffi::c_int;

unsafe extern "C" {
    safe fn abs(x: c_int) -> c_int;
}

fn main() {
    let x = -42;
    let abs_x = abs(x);
    println!("{x}, {abs_x}");
}

Обёртки для srand(3) и rand(3)

// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

use libc::{rand, srand};

// unsafe extern "C" {
//     /// Задаёт начальное значение генератора
//     fn srand(seed: std::ffi::c_uint);

//     fn rand() -> std::ffi::c_int;
// }

fn main() {
    unsafe { srand(12345) };

    let a = unsafe { rand() as i32 };
    println!("{a:?}");
}
This slide should take about 15 minutes.

Этот слайд демонстрирует, насколько легко обёртки вызывают неопределённое поведение, если написаны неправильно. Мы увидим, как легко вызвать проблемы с типобезопасностью.

Объясните, что функции rand и srand предоставляются стандартной библиотекой C (libc).

Объясните, что эти функции экспортируются крейтом libc, но мы также можем написать для них FFI-обёртку вручную.

Покажите вызов функций из экспортированных.

Код компилируется, потому что libc по умолчанию подключается к программам на Rust.

Объясните, что Rust вам поверит, если вы используете неправильный тип (или типы).

Измените fn rand() -> std::ffi::c_int;, чтобы он возвращал char.

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

Пример библиотеки на C

#ifndef TEXT_ANALYSIS_H
#define TEXT_ANALYSIS_H

#include <stddef.h>
#include <stdbool.h>

typedef struct TextAnalyst TextAnalyst;

typedef struct {
    const char* start;
    size_t length;
    size_t index;
} Token;

typedef enum {
    TA_OK = 0,
    TA_ERR_NULL_POINTER,
    TA_ERR_OUT_OF_MEMORY,
    TA_ERR_OTHER,
} TAError;

/* Возвращает `false`, если токен не найден. */ 
typedef bool (*Tokenizer)(Token* token, void* extra_context);


typedef bool (*TokenCallback)(void* user_context, Token* token, void* result);

/* Конструктор TextAnalyst */
TextAnalyst* ta_new(void);

/* Деструктор TextAnalyst */
void ta_free(TextAnalyst* ta);

/* Сбрасывает состояние, очищая текущий документ */ 
void ta_reset(TextAnalyst* ta);

/* Использовать собственный токенизатор (по умолчанию — пробельные символы) */ 
void ta_set_tokenizer(TextAnalyst* ta, Tokenizer* func);

TAError ta_set_text(TextAnalyst* ta, const char* text, size_t len, bool make_copy);

/* Применяет `callback` к каждому токену */
size_t ta_foreach_token(const TextAnalyst* ta, const TokenCallback* callback, void* user_context);

/* Получает понятное человеку сообщение об ошибке */
const char* ta_error_string(TAError error);

#endif /* TEXT_ANALYSIS_H */

Библиотеки на C скрывают детали реализации с помощью аргумента void*.

Рассмотрим заголовочный файл библиотеки обработки естественного языка, которая скрывает типы TextAnalyst и Analysis.

Это можно эмулировать в Rust с помощью похожего типа:

#![allow(unused)]
fn main() {
// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

#[repr(C)]
pub struct TextAnalyst {
    _private: [u8; 0],
}
}

Упражнение: попросите слушателей написать обёртку для этой библиотеки.

Предлагаемое решение

#![allow(unused)]
fn main() {
// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

// ffi.rs
use std::ffi::c_char;
use std::os::raw::c_void;

#[repr(C)]
pub struct TextAnalyst {
    _private: [u8; 0],
}

#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct Token {
    pub start: *const c_char,
    pub length: usize,
    pub index: usize,
}

#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TAError {
    Ok = 0,
    NullPointer = 1,
    OutOfMemory = 2,
    Other = 3,
}

pub type Tokenizer = Option<
    unsafe extern "C" fn(token: *mut Token, extra_context: *mut c_void) -> bool,
>;

pub type TokenCallback = Option<
    unsafe extern "C" fn(
        user_context: *mut c_void,
        token: *mut Token,
        result: *mut c_void,
    ) -> bool,
>;

unsafe extern "C" {
    pub fn ta_new() -> *mut TextAnalyst;

    pub fn ta_free(ta: *mut TextAnalyst);

    pub fn ta_reset(ta: *mut TextAnalyst);

    pub fn ta_set_tokenizer(ta: *mut TextAnalyst, func: *const Tokenizer);

    pub fn ta_set_text(
        ta: *mut TextAnalyst,
        text: *const c_char,
        len: usize,
        make_copy: bool,
    ) -> TAError;

    pub fn ta_foreach_token(
        ta: *const TextAnalyst,
        callback: *const TokenCallback,
        user_context: *mut c_void,
    ) -> usize;

    pub fn ta_error_string(error: TAError) -> *const c_char;
}
}

Пример: библиотека интернирования строк

Заголовок C++: interner.hpp

#ifndef INTERNER_HPP
#define INTERNER_HPP

#include <string>
#include <unordered_set>

class StringInterner {
    std::unordered_set<std::string> strings;

public:
    // Возвращает указатель на интернированную строку (действителен, пока жив interner)
    const char* intern(const char* s) {
        auto [it, _] = strings.emplace(s);
        return it->c_str();
    }

    size_t count() const {
        return strings.size();
    }
};

#endif

Заголовочный файл C: interner.h

// interner.h (C API для FFI)
#ifndef INTERNER_H
#define INTERNER_H

#include <stddef.h>

#ifdef __cplusplus
extern "C" {
#endif

typedef struct StringInterner StringInterner;

StringInterner* interner_new(void);
void interner_free(StringInterner* interner);
const char* interner_intern(StringInterner* interner, const char* s);
size_t interner_count(const StringInterner* interner);

#ifdef __cplusplus
}
#endif

Реализация на C++ (interner.cpp)

#include "interner.hpp"
#include "interner.h"

extern "C" {

StringInterner* interner_new(void) {
    return new StringInterner();
}

void interner_free(StringInterner* interner) {
    delete interner;
}

const char* interner_intern(StringInterner* interner, const char* s) {
    return interner->intern(s);
}

size_t interner_count(const StringInterner* interner) {
    return interner->count();
}

}
This slide should take about 30 minutes.

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

Предлагаемое решение

#![allow(unused)]
fn main() {
// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::ffi::{CStr, CString};
use std::marker::PhantomData;
use std::os::raw::c_char;

#[repr(C)]
pub struct StringInternerRaw {
    _opaque: [u8; 0],
    _pin: PhantomData<(*mut u8, std::marker::PhantomPinned)>,
}

unsafe extern "C" {
    fn interner_new() -> *mut StringInternerRaw;

    fn interner_free(interner: *mut StringInternerRaw);

    fn interner_intern(
        interner: *mut StringInternerRaw,
        s: *const c_char,
    ) -> *const c_char;

    fn interner_count(interner: *const StringInternerRaw) -> usize;
}
}

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

Спасибо!

Спасибо, что прошли Comprehensive Rust 🦀! Надеемся, вам понравилось и курс оказался полезным.

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

  • Спасибо, что читаете заметки для докладчика! Надеемся, они были полезны. Если вы найдёте страницы без заметок, пришлите нам PR и свяжите его с issue #1083. Мы также очень благодарны за исправления и улучшения существующих заметок.

Глоссарий

Ниже приведён глоссарий, который даёт краткое определение многим терминам Rust. Для переводов он также связывает термин с английским оригиналом.

  • выделение памяти: Динамическое выделение памяти в куче.
  • массив: Коллекция фиксированного размера из элементов одного типа, хранящихся в памяти непрерывно. См. Массивы.
  • ассоциированный тип: Тип, связанный с конкретным трейтом. Полезен для определения связи между типами.
  • Bare-metal Rust: Низкоуровневая разработка на Rust, часто для систем без операционной системы. См. Bare-metal Rust.
  • блок: См. Блоки и область видимости.
  • заимствование: См. Заимствование.
  • анализатор заимствований: Часть компилятора Rust, которая проверяет, что все заимствования корректны.
  • фигурная скобка: { и }. Также называются curly brace; они ограничивают блоки.
  • канал: Используется для безопасной передачи сообщений между потоками.
  • конкурентность: Одновременное выполнение нескольких задач или процессов. См. Добро пожаловать в конкурентность в Rust.
  • константа: Значение, которое не меняется во время выполнения программы. См. const.
  • поток управления: Порядок, в котором выполняются отдельные инструкции программы. См. Основы управления потоком.
  • аварийное завершение: Неожиданный и необработанный сбой или завершение программы. См. паника.
  • перечисление: Тип данных, который хранит одно из нескольких именованных значений, возможно, с ассоциированным кортежем или структурой. См. enum.
  • ошибка: Неожиданное условие или результат, отклоняющийся от ожидаемого поведения. См. Обработка ошибок.
  • обработка ошибок: Процесс управления ошибками, возникающими во время выполнения программы, и реагирования на них.
  • функция: Многократно используемый блок кода, который выполняет конкретную задачу. См. Функции.
  • сборщик мусора: Механизм, который автоматически освобождает память, занятую объектами, которые больше не используются. См. Подходы к управлению памятью.
  • обобщения: Возможность писать код с заполнителями вместо типов, что позволяет переиспользовать код с разными типами данных. См. Обобщения.
  • неизменяемый: Не может быть изменён после создания. См. Переменные.
  • интеграционный тест: Тип теста, который проверяет взаимодействие между различными частями или компонентами системы. См. Другие типы тестов.
  • библиотека: Набор предкомпилированных процедур или кода, которые могут использоваться программами. См. Модули.
  • макрос: Макросы Rust узнаются по ! в имени. Макросы используются, когда обычных функций недостаточно. Типичный пример — format!, который принимает переменное число аргументов, а функции Rust этого не поддерживают.
  • функция main: Программы на Rust начинают выполняться с функции main.
  • match: Конструкция управления потоком в Rust, которая позволяет выполнять сопоставление с образцом над значением выражения.
  • утечка памяти: Ситуация, когда программа не освобождает память, которая больше не нужна, из-за чего использование памяти постепенно растёт. См. Подходы к управлению памятью.
  • метод: Функция, связанная с объектом или типом в Rust. См. Методы.
  • модуль: Пространство имён, содержащее определения (функции, типы или трейты) для организации кода в Rust. См. Модули.
  • перемещение: Передача владения значением от одной переменной к другой в Rust. См. Семантика перемещения.
  • изменяемый: Свойство в Rust, которое позволяет переменным изменяться после объявления.
  • владение: Понятие Rust, которое определяет, какая часть кода отвечает за управление памятью, связанной со значением. См. Владение.
  • паника: Неустранимая ошибка в Rust, которая приводит к завершению программы. См. Паники.
  • шаблон: Сочетание значений, литералов или структур, с которым можно сопоставить выражение в Rust. См. Сопоставление с образцом.
  • полезная нагрузка: Данные или информация, которые переносятся сообщением, событием или структурой данных.
  • получатель: Первый параметр метода в Rust, который представляет экземпляр, для которого вызван метод.
  • ссылка: Невладеющий указатель на значение, которое заимствуется без передачи владения. Ссылки могут быть неизменяемыми (разделяемыми) или изменяемыми (эксклюзивными).
  • подсчёт ссылок: Техника управления памятью, при которой отслеживается число ссылок на объект, и объект освобождается, когда счётчик достигает нуля. См. Rc.
  • Rust: Системный язык программирования, в центре внимания которого безопасность, производительность и конкурентность. См. Что такое Rust?.
  • безопасный: Относится к коду, который соблюдает правила владения и заимствования Rust и предотвращает ошибки, связанные с памятью. См. Небезопасный Rust.
  • срез: Представление динамического размера над непрерывной последовательностью, например массивом или вектором. В отличие от массивов, размер срезов определяется во время выполнения. См. Срезы.
  • область видимости: Участок программы, в котором переменная действительна и может использоваться. См. Блоки и области видимости.
  • стандартная библиотека: Набор модулей, предоставляющих базовую функциональность Rust. См. Стандартная библиотека.
  • static: Ключевое слово Rust для определения статических переменных или элементов с временем жизни 'static. См. static.
  • строка: Тип данных для хранения текста. См. Строки.
  • структура: Составной тип данных в Rust, который объединяет переменные разных типов под одним именем. См. Структуры.
  • тест: Функция, которая проверяет корректность другого кода. В Rust есть встроенное средство запуска тестов. См. Тестирование.
  • поток: Отдельная последовательность выполнения в программе, которая позволяет выполнять код конкурентно. См. Потоки.
  • потокобезопасность: Свойство программы, которое обеспечивает корректное поведение в многопоточной среде. См. Send и Sync.
  • трейт: Набор методов, определённых для неизвестного типа, который даёт способ достичь полиморфизма в Rust. См. Трейты.
  • ограничение трейта: Абстракция, с помощью которой можно требовать, чтобы типы реализовывали определённые трейты. См. Ограничения трейтов.
  • кортеж: Составной тип данных, содержащий переменные разных типов. Поля кортежа не имеют имён и доступны по порядковым номерам. См. Кортежи.
  • тип: Классификация, которая определяет, какие операции можно выполнять над значениями данного вида в Rust. См. Типы и значения.
  • вывод типов: Способность компилятора Rust определить тип переменной или выражения. См. Вывод типов.
  • неопределённое поведение: Действия или условия в Rust, у которых нет определённого результата, что часто приводит к непредсказуемому поведению программы. См. Небезопасный Rust.
  • union: Тип данных, который может хранить значения разных типов, но только одно в каждый момент времени. См. Объединения.
  • модульный тест: В Rust есть встроенная поддержка запуска небольших модульных тестов и более крупных интеграционных тестов. См. Модульные тесты.
  • тип unit: Тип, который не хранит данных и записывается как кортеж без элементов. См. заметки для докладчика по функциям.
  • небезопасный: Подмножество Rust, которое позволяет вызывать неопределённое поведение. См. Небезопасный Rust.
  • переменная: Место в памяти, которое хранит данные. Переменные действительны в пределах области видимости. См. Переменные.

Другие ресурсы по Rust

Сообщество Rust создало множество качественных бесплатных ресурсов в интернете.

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

Проект Rust публикует много ресурсов. Вот общие:

  • The Rust Programming Language: основная бесплатная книга о Rust. Подробно описывает язык и содержит несколько проектов для самостоятельной сборки.
  • Rust By Example: описывает синтаксис Rust на серии примеров, демонстрирующих различные конструкции. Иногда содержит небольшие упражнения, в которых предлагается дополнить код из примеров.
  • Rust Standard Library: полная документация стандартной библиотеки Rust.
  • The Rust Reference: незавершённая книга, которая описывает грамматику Rust и модель памяти.
  • Rust API Guidelines: рекомендации по проектированию API.

Более специализированные руководства на официальном сайте Rust:

  • The Rustonomicon: описывает небезопасный Rust, включая работу с сырыми указателями и взаимодействие с другими языками (FFI).
  • Asynchronous Programming in Rust: описывает новую модель асинхронного программирования, появившуюся после написания The Rust Book.
  • The Embedded Rust Book: введение в использование Rust на встраиваемых устройствах без операционной системы.

Неофициальные учебные материалы

Небольшая подборка других руководств и учебных материалов по Rust:

  • Learn Rust the Dangerous Way: рассказывает о Rust с точки зрения программистов на низкоуровневом C.
  • Rust for Embedded C Programmers: рассказывает о Rust с точки зрения разработчиков, которые пишут прошивки на C.
  • Rust for professionals: описывает синтаксис Rust через сопоставление с другими языками, такими как C, C++, Java, JavaScript и Python.
  • Rust on Exercism: более 100 упражнений, которые помогут выучить Rust.
  • Ferrous Teaching Material: серия небольших презентаций, охватывающих базовую и продвинутую части языка Rust. Также рассматриваются такие темы, как WebAssembly и async/await.
  • Advanced testing for Rust applications: самостоятельный практикум, выходящий за рамки встроенного фреймворка тестирования Rust. Рассматриваются googletest, снапшот-тестирование, моки, а также написание собственной тестовой обвязки (test harness).
  • Beginner’s Series to Rust и Take your first steps with Rust: два руководства по Rust для начинающих разработчиков. Первое — набор из 35 видео, второе — 11 модулей, описывающих синтаксис Rust и базовые конструкции.
  • Learn Rust With Entirely Too Many Linked Lists: подробный разбор правил управления памятью в Rust через реализацию нескольких видов связных списков.
  • The Little Book of Rust Macros: подробно описывает макросы Rust на практических примерах.

Больше книг о Rust можно найти в Little Book of Rust Books.

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

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

Материалы Comprehensive Rust распространяются на условиях лицензии Apache 2.0. Подробности см. в файле LICENSE.

CXX

В разделе Взаимодействие с C++ используется изображение из CXX. Подробности, включая условия лицензии, см. в каталоге third_party/cxx/.