Курс Rust для программистов на C/C++
Обзор курса
- Обзор курса
- Аргументы в пользу Rust (с точки зрения и C, и C++)
- Локальная установка
- Типы, функции, управление потоком выполнения, сопоставление с образцом
- Модули, cargo
- Трейты, обобщения
- Коллекции, обработка ошибок
- Замыкания, управление памятью, времена жизни, умные указатели
- Конкурентность
- Небезопасный Rust, включая Foreign Function Interface (FFI)
no_stdи основы встраиваемого Rust для команд, разрабатывающих прошивки- Примеры из практики: типовые шаблоны перевода реального кода C++ на Rust
- Асинхронный Rust в этом курсе не рассматривается — см. сопутствующий курс по Async Rust, где подробно разобраны футуры, исполнители,
Pin, tokio и промышленные асинхронные шаблоны
Руководство для самостоятельного изучения
Материал подходит как для курса с преподавателем, так и для самостоятельного изучения. Если вы работаете с ним самостоятельно, вот как получить от него максимум пользы:
Рекомендации по темпу:
| Главы | Тема | Примерное время | Контрольная точка |
|---|---|---|---|
| 1–4 | Настройка, типы, управление потоком выполнения | 1 день | Вы можете написать CLI-конвертер температур |
| 5–7 | Структуры данных, владение | 1–2 дня | Вы можете объяснить, почему let s2 = s1 делает s1 недействительной |
| 8–9 | Модули, обработка ошибок | 1 день | Вы можете создать многофайловый проект, который передаёт ошибки дальше с помощью ? |
| 10–12 | Трейты, обобщения, замыкания | 1–2 дня | Вы можете написать обобщённую функцию с ограничениями трейтами |
| 13–14 | Конкурентность, unsafe/FFI | 1 день | Вы можете написать потокобезопасный счётчик с Arc<Mutex<T>> |
| 15–16 | Углублённые темы | В своём темпе | Справочный материал — читайте, когда это актуально |
| 17–19 | Лучшие практики и справочник | В своём темпе | Обращайтесь к нему, когда пишете реальный код |
Как работать с упражнениями:
- В каждой главе есть практические упражнения с указанием сложности: 🟢 Начальный, 🟡 Средний, 🔴 Сложный
- Всегда сначала попробуйте решить упражнение и только потом раскрывайте решение. Борьба с проверкой заимствований — часть обучения, а сообщения компилятора — ваш учитель
- Если вы застряли больше чем на 15 минут, раскройте решение, изучите его, затем закройте и попробуйте снова с нуля
- Rust Playground позволяет запускать код без локальной установки
Если вы упёрлись в стену:
- Внимательно читайте сообщение компилятора: ошибки Rust исключительно полезны
- Перечитайте нужный раздел. Концепции вроде владения (гл. 7) часто становятся понятны при втором прочтении
- Документация стандартной библиотеки Rust отличная: ищите любой тип или метод
- Более глубокие асинхронные шаблоны описаны в сопутствующем курсе Async Rust
Содержание
Часть I — Основы
1. Представление и общий подход
- Знакомство с курсом и общий подход
- Аргументы в пользу Rust
- Как Rust решает эти проблемы?
- Другие ключевые преимущества и возможности Rust
- Краткий справочник: Rust и C/C++
- Почему разработчикам C/C++ нужен Rust
2. Начало работы
- Хватит разговоров: покажите код
- Локальная установка Rust
- Пакеты Rust (крейты)
- Пример: cargo и крейты
3. Встроенные типы и переменные
- Встроенные типы Rust
- Указание типа и присваивание в Rust
- Указание типа и вывод типа в Rust
- Переменные и изменяемость в Rust
4. Управление потоком выполнения
5. Структуры данных и коллекции
- Массивы в Rust
- Кортежи в Rust
- Ссылки в Rust
- Ссылки C++ и ссылки Rust — ключевые различия
- Срезы в Rust
- Константы и статики в Rust
- Строки в Rust: String и &str
- Структуры в Rust
- Тип Vec<T> в Rust
- Тип HashMap в Rust
- Упражнение: Vec и HashMap
6. Сопоставление с образцом и перечисления
7. Владение и управление памятью
- Управление памятью в Rust
- Владение, заимствование и времена жизни в Rust
- Семантика перемещения в Rust
- Clone в Rust
- Трейт Copy в Rust
- Трейт Drop в Rust
- Упражнение: перемещение, копирование и Drop
- Времена жизни и заимствование в Rust
- Аннотации времён жизни в Rust
- Упражнение: хранение срезов с временами жизни
- Углублённый разбор правил элизии времён жизни
- Box<T> в Rust
- Внутренняя изменяемость: Cell<T> и RefCell<T>
- Совместное владение: Rc<T>
- Упражнение: совместное владение и внутренняя изменяемость
8. Модули и крейты
- Крейты и модули в Rust
- Упражнение: модули и функции
- Рабочие пространства и крейты (пакеты)
- Упражнение: использование рабочих пространств и зависимостей пакетов
- Использование крейтов сообщества из crates.io
- Зависимости крейтов и SemVer
- Упражнение: использование крейта rand
- Cargo.toml и Cargo.lock
- Функция cargo test
- Другие возможности Cargo
- Паттерны тестирования
9. Обработка ошибок
- Связываем перечисления с Option и Result
- Тип Option в Rust
- Тип Result в Rust
- Упражнение: реализация функции log() с Option
- Обработка ошибок в Rust
- Упражнение: обработка ошибок
- Лучшие практики обработки ошибок
10. Трейты и обобщения
- Трейты в Rust
- Перегрузка операторов в C++ → трейты std::ops в Rust
- Упражнение: реализация трейта Logger
- Когда использовать enum и dyn Trait
- Упражнение: подумайте, прежде чем переводить
- Обобщения в Rust
- Упражнение: обобщения
- Комбинирование трейтов и обобщений в Rust
- Ограничения трейтов в типах данных
- Упражнение: ограничения трейтов и обобщения
- Паттерн состояний (type state) и обобщения в Rust
- Паттерн-строитель в Rust
11. Расширенные возможности системы типов
- Трейты From и Into в Rust
- Упражнение: From и Into
- Трейт Default в Rust
- Другие преобразования типов в Rust
12. Функциональное программирование
- Замыкания в Rust
- Упражнение: замыкания и захват переменных
- Итераторы в Rust
- Упражнение: итераторы в Rust
- Справочник по инструментам для работы с итераторами
13. Конкурентность
- Конкурентность в Rust
- Почему Rust предотвращает гонки данных: Send и Sync
- Упражнение: подсчёт слов в многопоточном режиме
14. Небезопасный Rust и FFI
- Небезопасный Rust (unsafe)
- Простой пример FFI (функция Rust, вызываемая из C)
- Сложный пример FFI
- Обеспечение корректности unsafe-кода
- Упражнение: написание безопасной обёртки FFI
Часть II — Углублённые темы
15. no_std — Rust для bare metal
- Что такое no_std?
- Когда использовать no_std, а когда std
- Упражнение: кольцевой буфер no_std
- Углублённо о встраиваемых системах
16. Примеры из практики: перевод реального кода C++ на Rust
- Кейс 1: иерархия наследования → диспетчеризация через перечисления
- Кейс 2: дерево на shared_ptr → паттерн арены и индексов
- Кейс 3: коммуникация с фреймворком → заимствование по времени жизни
- Кейс 4: «божественный объект» → компонуемое состояние
- Кейс 5: трейт-объекты — когда они действительно уместны
Часть III — Лучшие практики и справочник
17. Лучшие практики
- Сводка лучших практик Rust
- Избегание избыточного clone()
- Избегание неконтролируемой индексации
- Схлопывание пирамид присваиваний
- Итоговое упражнение: конвейер диагностических событий
- Экосистема логирования и трассировки
18. Семантические различия C++ → Rust
19. Макросы Rust
- Декларативные макросы (
macro_rules!) - Распространённые макросы стандартной библиотеки
- Макросы derive
- Макросы-атрибуты
- Процедурные макросы
- Когда что использовать: макросы, функции или обобщения
- Упражнения
Знакомство с курсом и общий подход
Что вы узнаете: структуру курса, интерактивный формат и то, как знакомые концепции C/C++ соответствуют своим аналогам в Rust. Эта глава задаёт ожидания и даёт дорожную карту для остальной книги.
- О курсе
- Курс основан на оригинальных материалах Rust Training (лицензии MIT и CC-BY-4.0)
- Русский перевод и адаптация выполнены участниками проекта RustTraining-Rus
- Курс задуман как можно более интерактивный
- Предположение: вы знаете C, C++ или оба языка
- Примеры намеренно построены так, чтобы знакомые концепции сопоставлялись с аналогами в Rust
- Задавайте уточняющие вопросы в любой момент
- Преподаватель рассчитывает на продолжение общения с командами
Аргументы в пользу Rust
Хотите сразу перейти к коду? Перейдите к разделу Покажите мне код
Независимо от того, приходите ли вы из C или C++, основные болевые точки одни и те же: ошибки безопасности памяти, которые компилируются без проблем, но приводят к падению, порче данных или утечкам во время выполнения.
- Более 70% CVE вызваны проблемами безопасности памяти — переполнениями буфера, висячими указателями, использованием после освобождения
- Умные указатели C++ (
shared_ptr,unique_ptr), RAII и семантика перемещения — шаги в правильном направлении, но это пластыри, а не лекарство: они оставляют открытыми использование после перемещения, циклы ссылок, инвалидацию итераторов и пробелы в безопасности исключений - Rust даёт ту же производительность, на которую вы опираетесь в C/C++, но с гарантиями безопасности на этапе компиляции
📖 Подробнее: см. Почему разработчикам C/C++ нужен Rust — там конкретные примеры уязвимостей, полный список того, что устраняет Rust, и объяснение, почему умных указателей C++ недостаточно
Как Rust решает эти проблемы?
Переполнения буфера и выход за границы
- У всех массивов, срезов и строк Rust явно заданы границы. Компилятор вставляет проверки, благодаря которым любой выход за границы приводит к аварийному завершению во время выполнения (panic в терминологии Rust), а не к неопределённому поведению
Висячие указатели и ссылки
- Rust вводит времена жизни и проверку заимствований, чтобы устранять висячие ссылки на этапе компиляции
- Никаких висячих указателей, никакого использования после освобождения — компилятор просто не позволит это сделать
Использование после перемещения
- Система владения в Rust делает перемещение разрушающим: после перемещения значения компилятор не позволит использовать исходную переменную. Никаких «зомби-объектов» и состояния «допустимо, но не определено»
Управление ресурсами
- Трейт
Dropв Rust — это RAII, реализованный правильно: компилятор автоматически освобождает ресурсы, когда они выходят из области видимости, и предотвращает использование после перемещения, чего RAII в C++ не может - Правило пяти не нужно (не требуется определять конструктор копирования, конструктор перемещения, оператор копирующего присваивания, оператор перемещающего присваивания и деструктор)
Обработка ошибок
- В Rust нет исключений. Все ошибки — это значения (
Result<T, E>), поэтому обработка ошибок явная и видна в сигнатуре типа
Инвалидация итераторов
- Проверка заимствований в Rust запрещает изменять коллекцию во время итерации по ней. Вы просто не можете написать те ошибки, которыми полны кодовые базы на C++:
#![allow(unused)]
fn main() {
// Аналог удаления во время итерации в Rust: retain()
pending_faults.retain(|f| f.id != fault_to_remove.id);
// Или: собрать в новый Vec (функциональный стиль)
let remaining: Vec<_> = pending_faults
.into_iter()
.filter(|f| f.id != fault_to_remove.id)
.collect();
}
Гонки данных
- Система типов предотвращает гонки данных на этапе компиляции благодаря трейтам
SendиSync
Визуализация безопасности памяти
Владение в Rust — безопасность по замыслу
#![allow(unused)]
fn main() {
fn safe_rust_ownership() {
// Перемещение разрушающее: исходное значение больше недоступно
let data = vec![1, 2, 3];
let data2 = data; // Происходит перемещение
// data.len(); // Ошибка компиляции: значение использовано после перемещения
// Заимствование: безопасный совместный доступ
let owned = String::from("Hello, World!");
let slice: &str = &owned; // Заимствование — без выделения памяти
println!("{}", slice); // Всегда безопасно
// Висячие ссылки невозможны
/*
let dangling_ref;
{
let temp = String::from("temporary");
dangling_ref = &temp; // Ошибка компиляции: temp не живёт достаточно долго
}
*/
}
}
graph TD
A[Безопасность владения Rust] --> B[Разрушающие перемещения]
A --> C[Автоматическое управление памятью]
A --> D[Проверка времён жизни на этапе компиляции]
A --> E[Никаких исключений — типы Result]
B --> B1["Использование после перемещения — ошибка компиляции"]
B --> B2["Никаких зомби-объектов"]
C --> C1["Трейт Drop = RAII, сделанный правильно"]
C --> C2["Правило пяти не нужно"]
D --> D1["Проверка заимствований исключает висячие ссылки"]
D --> D2["Ссылки всегда валидны"]
E --> E1["Result<T,E> — ошибки в типах"]
E --> E2["Оператор ? для распространения"]
style A fill:#51cf66,color:#000
style B fill:#91e5a3,color:#000
style C fill:#91e5a3,color:#000
style D fill:#91e5a3,color:#000
style E fill:#91e5a3,color:#000
Структура памяти: ссылки в Rust
graph TD
RM1[Стек] --> RP1["Ссылка &i32"]
RM2[Стек/Куча] --> RV1["Значение i32 = 42"]
RP1 -.->|"Безопасная ссылка — время жизни проверяется"| RV1
RM3[Проверка заимствований] --> RC1["Исключает висячие ссылки на этапе компиляции"]
style RC1 fill:#51cf66,color:#000
style RP1 fill:#91e5a3,color:#000
Визуализация выделения памяти в Box<T>
#![allow(unused)]
fn main() {
fn box_allocation_example() {
// Выделение на стеке
let stack_value = 42;
// Выделение в куче через Box
let heap_value = Box::new(42);
// Передача владения
let moved_box = heap_value;
// heap_value больше недоступен
}
}
graph TD
subgraph "Кадр стека"
SV["stack_value: 42"]
BP["heap_value: Box<i32>"]
BP2["moved_box: Box<i32>"]
end
subgraph "Куча"
HV["42"]
end
BP -->|"Владеет"| HV
BP -.->|"Передача владения"| BP2
BP2 -->|"Теперь владеет"| HV
subgraph "После перемещения"
BP_X["heap_value: [ВНИМАНИЕ] ПЕРЕМЕЩЕНО"]
BP2_A["moved_box: Box<i32>"]
end
BP2_A -->|"Владеет"| HV
style BP_X fill:#ff6b6b,color:#000
style HV fill:#91e5a3,color:#000
style BP2_A fill:#51cf66,color:#000
Визуализация операций со срезами
#![allow(unused)]
fn main() {
fn slice_operations() {
let data = vec![1, 2, 3, 4, 5, 6, 7, 8];
let full_slice = &data[..]; // [1,2,3,4,5,6,7,8]
let partial_slice = &data[2..6]; // [3,4,5,6]
let from_start = &data[..4]; // [1,2,3,4]
let to_end = &data[3..]; // [4,5,6,7,8]
}
}
graph TD
V["Vec: [1, 2, 3, 4, 5, 6, 7, 8]"]
V --> FS["&data[..] → все элементы"]
V --> PS["&data[2..6] → [3, 4, 5, 6]"]
V --> SS["&data[..4] → [1, 2, 3, 4]"]
V --> ES["&data[3..] → [4, 5, 6, 7, 8]"]
style V fill:#e3f2fd,color:#000
style FS fill:#91e5a3,color:#000
style PS fill:#91e5a3,color:#000
style SS fill:#91e5a3,color:#000
style ES fill:#91e5a3,color:#000
Другие ключевые преимущества и возможности Rust
- Никаких гонок данных между потоками (проверка
Send/Syncна этапе компиляции) - Никакого использования после перемещения (в отличие от
std::moveв C++, который оставляет зомби-объекты) - Никаких неинициализированных переменных
- Все переменные должны быть инициализированы до использования
- Никаких тривиальных утечек памяти
- Трейт
Drop= RAII, сделанный правильно, правило пяти не нужно - Компилятор автоматически освобождает память, когда она выходит из области видимости
- Трейт
- Никаких забытых блокировок мьютексов
- Защитники блокировок — единственный способ доступа к данным (
Mutex<T>оборачивает данные, а не доступ к ним)
- Защитники блокировок — единственный способ доступа к данным (
- Никакой сложности обработки исключений
- Ошибки — это значения (
Result<T, E>), видны в сигнатурах функций и распространяются с помощью?
- Ошибки — это значения (
- Отличная поддержка вывода типов, перечислений, сопоставления с образцом и абстракций с нулевой стоимостью
- Встроенная поддержка управления зависимостями, сборки, тестирования, форматирования и линтинга
cargoзаменяет make/CMake и фреймворки для линтинга и тестирования
Краткий справочник: Rust и C/C++
| Концепция | C | C++ | Rust | Ключевое отличие |
|---|---|---|---|---|
| Управление памятью | malloc()/free() | unique_ptr, shared_ptr | Box<T>, Rc<T>, Arc<T> | Автоматическое, без циклов |
| Массивы | int arr[10] | std::vector<T>, std::array<T> | Vec<T>, [T; N] | Проверка границ по умолчанию |
| Строки | char* с \0 | std::string, string_view | String, &str | Гарантированный UTF-8, проверка времён жизни |
| Ссылки | int* ptr | T&, T&& (перемещение) | &T, &mut T | Проверка заимствований, времена жизни |
| Полиморфизм | Указатели на функции | Виртуальные функции, наследование | Трейты, трейт-объекты | Композиция вместо наследования |
| Обобщённое программирование | Макросы (void*) | Шаблоны | Обобщения + ограничения трейтами | Понятные сообщения об ошибках |
| Обработка ошибок | Коды возврата, errno | Исключения, std::optional | Result<T, E>, Option<T> | Никакого скрытого потока управления |
| Безопасность при null | ptr == NULL | nullptr, std::optional<T> | Option<T> | Принудительная проверка на null |
| Потокобезопасность | Вручную (pthreads) | Вручную (std::mutex и др.) | Гарантии на этапе компиляции | Гонки данных невозможны |
| Система сборки | Make, CMake | CMake, Make и др. | Cargo | Встроенный инструментарий |
| Неопределённое поведение | Ошибки во время выполнения | Тонкое (переполнение знаковых, алиасинг) | Ошибки на этапе компиляции | Безопасность гарантирована |
Почему разработчикам C/C++ нужен Rust
Что вы узнаете:
- Полный список проблем, которые устраняет Rust: безопасность памяти, неопределённое поведение, гонки данных и многое другое
- Почему
shared_ptr,unique_ptrи другие средства C++ — это пластыри, а не решения- Конкретные примеры уязвимостей C и C++, которые в безопасном Rust структурно невозможны
Хотите сразу перейти к коду? Перейдите к разделу Покажите мне код
Что Rust устраняет — полный список
Прежде чем переходить к примерам, вот краткое резюме. Безопасный Rust структурно предотвращает каждую проблему из этого списка — не дисциплиной, инструментами или код-ревью, а системой типов и компилятором:
| Устранённая проблема | C | C++ | Как Rust её предотвращает |
|---|---|---|---|
| Переполнения и недополнения буфера | ✅ | ✅ | У всех массивов, срезов и строк есть границы; индексация проверяется во время выполнения |
| Утечки памяти (без сборщика мусора) | ✅ | ✅ | Трейт Drop = RAII, сделанный правильно; автоматическая очистка, правило пяти не нужно |
| Висячие указатели | ✅ | ✅ | Система времён жизни доказывает, что ссылки переживают объект, на который ссылаются, на этапе компиляции |
| Использование после освобождения | ✅ | ✅ | Система владения делает это ошибкой компиляции |
| Использование после перемещения | — | ✅ | Перемещения разрушающие — исходная привязка перестаёт существовать |
| Неинициализированные переменные | ✅ | ✅ | Все переменные должны быть инициализированы до использования; это обеспечивает компилятор |
| Неопределённое поведение при переполнении целых | ✅ | ✅ | В отладочных сборках переполнение вызывает panic; в релизных — переполняется (в обоих случаях поведение определено) |
| Разыменование NULL-указателей / SEGV | ✅ | ✅ | Нулевых указателей нет; Option<T> вынуждает явно обрабатывать отсутствие значения |
| Гонки данных | ✅ | ✅ | Трейты Send/Sync и проверка заимствований делают гонки данных ошибкой компиляции |
| Неконтролируемые побочные эффекты | ✅ | ✅ | Неизменяемость по умолчанию; для изменения нужен явный mut |
| Отсутствие наследования (лучшая сопровождаемость) | — | ✅ | Трейты и композиция заменяют иерархии классов; способствуют повторному использованию без жёсткой связанности |
| Отсутствие исключений; предсказуемый поток управления | — | ✅ | Ошибки — это значения (Result<T, E>); их невозможно проигнорировать, нет скрытых путей throw |
| Инвалидация итераторов | — | ✅ | Проверка заимствований запрещает изменять коллекцию во время итерации по ней |
| Циклы ссылок / утечки финализаторов | — | ✅ | Владение имеет древовидную структуру; циклы Rc — явный выбор, который можно обнаружить и разорвать с помощью Weak |
| Забытые разблокировки мьютексов | ✅ | ✅ | Mutex<T> оборачивает данные; защитник блокировки — единственный способ доступа к ним |
| Неопределённое поведение (вообще) | ✅ | ✅ | В безопасном Rust нет неопределённого поведения; блоки unsafe явные и поддаются аудиту |
Главное: это не желаемые цели, которые навязываются правилами кодирования. Это гарантии на этапе компиляции. Если ваш код компилируется, эти ошибки не могут существовать.
Проблемы, общие для C и C++
Хотите пропустить примеры? Перейдите к разделу Как Rust решает всё это или сразу к Покажите мне код
Оба языка разделяют базовый набор проблем безопасности памяти, которые являются первопричиной более 70% CVE (Common Vulnerabilities and Exposures — общеизвестных уязвимостей и подверженностей):
Переполнения буфера
Массивы, указатели и строки в C не имеют встроенных границ. Выйти за них очень легко:
#include <stdlib.h>
#include <string.h>
void buffer_dangers() {
char buffer[10];
strcpy(buffer, "This string is way too long!"); // Переполнение буфера
int arr[5] = {1, 2, 3, 4, 5};
int *ptr = arr; // Теряется информация о размере
ptr[10] = 42; // Нет проверки границ — неопределённое поведение
}
В C++ std::vector::operator[] по-прежнему не проверяет границы. Проверяет только .at() — но кто поймает исключение?
Висячие указатели и использование после освобождения
int *bar() {
int i = 42;
return &i; // Возвращается адрес переменной на стеке — висячий указатель!
}
void use_after_free() {
char *p = (char *)malloc(20);
free(p);
*p = '\0'; // Использование после освобождения — неопределённое поведение
}
Неинициализированные переменные и неопределённое поведение
И C, и C++ допускают неинициализированные переменные. Их значения не определены, и чтение таких значений — неопределённое поведение:
int x; // Не инициализирована
if (x > 0) { ... } // Неопределённое поведение — x может быть чем угодно
Переполнение целых в C определено для беззнаковых типов, но не определено для знаковых. В C++ переполнение знакового типа тоже является неопределённым поведением. Оба компилятора могут и действительно используют это для «оптимизаций», которые ломают программы самым неожиданным образом.
Разыменование NULL-указателей
int *ptr = NULL;
*ptr = 42; // SEGV — но компилятор вас не остановит
В C++ std::optional<T> помогает, но он громоздкий, и его часто обходят вызовом .value(), который выбрасывает исключение.
Визуализация: общие проблемы
graph TD
ROOT["Проблемы безопасности памяти в C/C++"] --> BUF["Переполнения буфера"]
ROOT --> DANGLE["Висячие указатели"]
ROOT --> UAF["Использование после освобождения"]
ROOT --> UNINIT["Неинициализированные переменные"]
ROOT --> NULL["Разыменование NULL"]
ROOT --> UB["Неопределённое поведение"]
ROOT --> RACE["Гонки данных"]
BUF --> BUF1["Нет границ у массивов/указателей"]
DANGLE --> DANGLE1["Возврат адресов со стека"]
UAF --> UAF1["Повторное использование освобождённой памяти"]
UNINIT --> UNINIT1["Неопределённые значения"]
NULL --> NULL1["Нет принудительной проверки на null"]
UB --> UB1["Переполнение знаковых, алиасинг"]
RACE --> RACE1["Нет безопасности на этапе компиляции"]
style ROOT fill:#ff6b6b,color:#000
style BUF fill:#ffa07a,color:#000
style DANGLE fill:#ffa07a,color:#000
style UAF fill:#ffa07a,color:#000
style UNINIT fill:#ffa07a,color:#000
style NULL fill:#ffa07a,color:#000
style UB fill:#ffa07a,color:#000
style RACE fill:#ffa07a,color:#000
C++ добавляет новые проблемы
Читатели, знакомые только с C: можете сразу перейти к разделу Как Rust решает эти проблемы, если не используете C++.
Хотите сразу перейти к коду? Перейдите к разделу Покажите мне код
C++ ввёл умные указатели, RAII, семантику перемещения и исключения, чтобы решить проблемы C. Это пластыри, а не лекарства — они переносят сбой из режима «падение во время выполнения» в режим «более тонкая ошибка во время выполнения»:
unique_ptr и shared_ptr — пластыри, а не решения
Умные указатели C++ — заметное улучшение по сравнению с сырыми malloc/free, но они не решают основных проблем:
| Средство C++ | Что исправляет | Что не исправляет |
|---|---|---|
std::unique_ptr | Предотвращает утечки через RAII | Использование после перемещения по-прежнему компилируется; остаётся «зомби»-nullptr |
std::shared_ptr | Совместное владение | Циклы ссылок незаметно приводят к утечкам; дисциплина weak_ptr — ручная |
std::optional | Заменяет часть использований null | .value() выбрасывает исключение, если значения нет — скрытый поток управления |
std::string_view | Избегает копирования | Висячий, если исходная строка освобождена — нет проверки времён жизни |
| Семантика перемещения | Эффективная передача | Объекты после перемещения находятся в «допустимом, но не определённом состоянии» — неопределённое поведение, которое только ждёт своего часа |
| RAII | Автоматическая очистка | Чтобы всё сделать правильно, нужно правило пяти; одна ошибка ломает всё |
// unique_ptr: использование после перемещения компилируется без проблем
std::unique_ptr<int> ptr = std::make_unique<int>(42);
std::unique_ptr<int> ptr2 = std::move(ptr);
std::cout << *ptr; // Компилируется! Неопределённое поведение во время выполнения.
// В Rust это ошибка компиляции: "value used after move"
// shared_ptr: циклы ссылок незаметно приводят к утечкам
struct Node {
std::shared_ptr<Node> next;
std::shared_ptr<Node> parent; // Цикл! Деструктор никогда не вызывается.
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->parent = a; // Утечка памяти — счётчик ссылок никогда не станет 0
// В Rust Rc<T> + Weak<T> делают циклы явными и разрываемыми
Использование после перемещения — тихий убийца
std::move в C++ — это не перемещение, а приведение типа. Исходный объект остаётся в «допустимом, но не определённом состоянии». Компилятор позволяет продолжать его использовать:
auto vec = std::make_unique<std::vector<int>>({1, 2, 3});
auto vec2 = std::move(vec);
vec->size(); // Компилируется! Но разыменование nullptr — падение во время выполнения
В Rust перемещения разрушающие. Исходной привязки больше нет:
#![allow(unused)]
fn main() {
let vec = vec![1, 2, 3];
let vec2 = vec; // Перемещение — vec поглощён
// vec.len(); // Ошибка компиляции: значение использовано после перемещения
}
Инвалидация итераторов — настоящие ошибки из боевого C++
Это не надуманные примеры — они представляют собой реальные шаблоны ошибок, встречающиеся в больших кодовых базах на C++:
// ОШИБКА 1: удаление без переприсваивания итератора (неопределённое поведение)
while (it != pending_faults.end()) {
if (*it != nullptr && (*it)->GetId() == fault->GetId()) {
pending_faults.erase(it); // ← итератор инвалидирован!
removed_count++; // следующая итерация использует висячий итератор
} else {
++it;
}
}
// Исправление: it = pending_faults.erase(it);
// ОШИБКА 2: удаление по индексу пропускает элементы
for (auto i = 0; i < entries.size(); i++) {
if (config_status == ConfigDisable::Status::Disabled) {
entries.erase(entries.begin() + i); // ← сдвигает элементы
} // i++ пропускает сдвинутый элемент
}
// ОШИБКА 3: один путь удаления правильный, другой — нет
while (it != incomplete_ids.end()) {
if (current_action == nullptr) {
incomplete_ids.erase(it); // ← ОШИБКА: итератор не переприсвоен
continue;
}
it = incomplete_ids.erase(it); // ← Правильный путь
}
Все эти примеры компилируются без единого предупреждения. В Rust проверка заимствований делает все три ошибки ошибками компиляции — вы не можете изменять коллекцию во время итерации по ней, точка.
Безопасность исключений и шаблон dynamic_cast/new
Современные кодовые базы на C++ по-прежнему опираются на шаблоны, у которых нет безопасности на этапе компиляции:
// Типичный фабричный шаблон C++ — каждая ветка является потенциальной ошибкой
DriverBase* driver = nullptr;
if (dynamic_cast<ModelA*>(device)) {
driver = new DriverForModelA(framework);
} else if (dynamic_cast<ModelB*>(device)) {
driver = new DriverForModelB(framework);
}
// Что, если driver всё ещё nullptr? Что, если new выбросит исключение? Кто владеет driver?
В типичной кодовой базе на C++ на 100 тысяч строк можно найти сотни вызовов dynamic_cast (каждый — потенциальный сбой во время выполнения), сотни сырых вызовов new (каждый — потенциальная утечка) и сотни методов virtual/override (накладные расходы на таблицы виртуальных функций повсюду).
Висячие ссылки и захваты в лямбдах
int& get_reference() {
int x = 42;
return x; // Висячая ссылка — компилируется, неопределённое поведение во время выполнения
}
auto make_closure() {
int local = 42;
return [&local]() { return local; }; // Висячий захват!
}
Визуализация: дополнительные проблемы C++
graph TD
ROOT["Дополнительные проблемы C++<br/>(помимо проблем C)"] --> UAM["Использование после перемещения"]
ROOT --> CYCLE["Циклы ссылок"]
ROOT --> ITER["Инвалидация итераторов"]
ROOT --> EXC["Безопасность исключений"]
ROOT --> TMPL["Сообщения об ошибках шаблонов"]
UAM --> UAM1["std::move оставляет зомби<br/>Компилируется без предупреждений"]
CYCLE --> CYCLE1["Циклы shared_ptr приводят к утечкам<br/>Деструктор никогда не вызывается"]
ITER --> ITER1["erase() инвалидирует итераторы<br/>Реальные ошибки в продакшене"]
EXC --> EXC1["Частичное конструирование<br/>new без try/catch"]
TMPL --> TMPL1["30+ строк вложенных ошибок<br/>инстанцирования шаблонов"]
style ROOT fill:#ff6b6b,color:#000
style UAM fill:#ffa07a,color:#000
style CYCLE fill:#ffa07a,color:#000
style ITER fill:#ffa07a,color:#000
style EXC fill:#ffa07a,color:#000
style TMPL fill:#ffa07a,color:#000
Как Rust решает всё это
Каждая проблема, перечисленная выше — и из C, и из C++, — предотвращается гарантиями Rust на этапе компиляции:
| Проблема | Решение в Rust |
|---|---|
| Переполнения буфера | Срезы хранят длину; индексация проверяется на границы |
| Висячие указатели / использование после освобождения | Система времён жизни доказывает, что ссылки валидны, на этапе компиляции |
| Использование после перемещения | Перемещения разрушающие — компилятор не позволит трогать исходное значение |
| Утечки памяти | Трейт Drop = RAII без правила пяти; автоматическая и корректная очистка |
| Циклы ссылок | Владение имеет древовидную структуру; Rc + Weak делают циклы явными |
| Инвалидация итераторов | Проверка заимствований запрещает изменять коллекцию, пока она заимствована |
| NULL-указатели | Нет null. Option<T> вынуждает явно обрабатывать значение через сопоставление с образцом |
| Гонки данных | Трейты Send/Sync делают гонки данных ошибкой компиляции |
| Неинициализированные переменные | Все переменные должны быть инициализированы; это проверяет компилятор |
| Неопределённое поведение при переполнении | В отладке panic; в релизе переполнение (оба варианта определены) |
| Исключения | Исключений нет; Result<T, E> виден в сигнатурах типов и распространяется с помощью ? |
| Сложность наследования | Трейты + композиция; нет проблемы ромба, нет хрупкости таблиц виртуальных функций |
| Забытые разблокировки мьютексов | Mutex<T> оборачивает данные; защитник блокировки — единственный путь доступа |
#![allow(unused)]
fn main() {
fn rust_prevents_everything() {
// ✅ Нет переполнения буфера — границы проверяются
let arr = [1, 2, 3, 4, 5];
// arr[10]; // panic во время выполнения, никогда не UB
// ✅ Нет использования после перемещения — ошибка компиляции
let data = vec![1, 2, 3];
let moved = data;
// data.len(); // error: value used after move
// ✅ Нет висячих указателей — ошибка времени жизни
// let r;
// { let x = 5; r = &x; } // error: x does not live long enough
// ✅ Нет null — Option вынуждает обработку
let maybe: Option<i32> = None;
// maybe.unwrap(); // panic, но вместо этого вы бы использовали match или if let
// ✅ Нет гонки данных — ошибка компиляции
// let mut shared = vec![1, 2, 3];
// std::thread::spawn(|| shared.push(4)); // error: closure may outlive
// shared.push(5); // borrowed value
}
}
Модель безопасности Rust — полная картина
graph TD
RUST["Гарантии безопасности Rust"] --> OWN["Система владения"]
RUST --> BORROW["Проверка заимствований"]
RUST --> TYPES["Система типов"]
RUST --> TRAITS["Трейты Send/Sync"]
OWN --> OWN1["Нет использования после освобождения<br/>Нет использования после перемещения<br/>Нет двойного освобождения"]
BORROW --> BORROW1["Нет висячих ссылок<br/>Нет инвалидации итераторов<br/>Нет гонок данных через ссылки"]
TYPES --> TYPES1["Нет NULL (Option<T>)<br/>Нет исключений (Result<T,E>)<br/>Нет неинициализированных значений"]
TRAITS --> TRAITS1["Нет гонок данных<br/>Send = безопасно передавать<br/>Sync = безопасно разделять"]
style RUST fill:#51cf66,color:#000
style OWN fill:#91e5a3,color:#000
style BORROW fill:#91e5a3,color:#000
style TYPES fill:#91e5a3,color:#000
style TRAITS fill:#91e5a3,color:#000
Краткий справочник: C vs C++ vs Rust
| Концепция | C | C++ | Rust | Ключевое отличие |
|---|---|---|---|---|
| Управление памятью | malloc()/free() | unique_ptr, shared_ptr | Box<T>, Rc<T>, Arc<T> | Автоматическое, без циклов и зомби |
| Массивы | int arr[10] | std::vector<T>, std::array<T> | Vec<T>, [T; N] | Проверка границ по умолчанию |
| Строки | char* с \0 | std::string, string_view | String, &str | Гарантированный UTF-8, проверка времён жизни |
| Ссылки | int* (сырые) | T&, T&& (перемещение) | &T, &mut T | Проверка времён жизни и заимствований |
| Полиморфизм | Указатели на функции | Виртуальные функции, наследование | Трейты, трейт-объекты | Композиция вместо наследования |
| Обобщения | Макросы / void* | Шаблоны | Обобщения + ограничения трейтами | Понятные сообщения об ошибках |
| Обработка ошибок | Коды возврата, errno | Исключения, std::optional | Result<T, E>, Option<T> | Никакого скрытого потока управления |
| Безопасность при null | ptr == NULL | nullptr, std::optional<T> | Option<T> | Принудительная проверка на null |
| Потокобезопасность | Вручную (pthreads) | Вручную (std::mutex и др.) | Send/Sync на этапе компиляции | Гонки данных невозможны |
| Система сборки | Make, CMake | CMake, Make и др. | Cargo | Интегрированный инструментарий |
| Неопределённое поведение | Повсеместно | Тонкое (переполнение знаковых, алиасинг) | Нет в безопасном коде | Безопасность гарантирована |
Хватит разговоров: покажите код
Что вы узнаете: вашу первую программу на Rust —
fn main(),println!()и то, чем макросы Rust принципиально отличаются от препроцессорных макросов C/C++. К концу главы вы сможете написать, скомпилировать и запустить простые программы на Rust.
fn main() {
println!("Hello world from Rust");
}
- Этот синтаксис должен быть понятен всем, кто знаком с языками в стиле C
- Все функции в Rust начинаются с ключевого слова
fn - Точка входа по умолчанию для исполняемых файлов —
main() println!выглядит как функция, но на самом деле это макрос. Макросы в Rust сильно отличаются от препроцессорных макросов C/C++: они гигиеничны, типобезопасны и работают с синтаксическим деревом, а не с текстовой подстановкой
- Все функции в Rust начинаются с ключевого слова
- Два удобных способа быстро опробовать фрагменты кода на Rust:
- Онлайн: Rust Playground — вставьте код, нажмите Run и поделитесь результатом. Установка не нужна
- Локальный REPL: установите
evcxr_replдля интерактивного REPL Rust (как REPL Python, только для Rust):
cargo install --locked evcxr_repl
evcxr # Запустить REPL и вводить выражения Rust интерактивно
Локальная установка Rust
- Rust можно установить локально одним из следующих способов
- Windows: https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe
- Linux / WSL:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
- Экосистема Rust состоит из следующих компонентов
rustc— самостоятельный компилятор, но им редко пользуются напрямую- Основной инструмент —
cargo, швейцарский армейский нож для управления зависимостями, сборки, тестирования, форматирования, линтинга и т. д. - Тулчейн Rust бывает в каналах
stable,betaиnightly(экспериментальный), но мы будем использоватьstable. Командаrustup updateобновляет установкуstable, которая выходит каждые шесть недель
- Мы также установим плагин
rust-analyzerдля VSCode
Пакеты Rust (крейты)
- Бинарные файлы Rust создаются из пакетов (далее — крейты)
- Крейт может быть самостоятельным или зависеть от других крейтов. Крейты-зависимости могут быть локальными или удалёнными. Сторонние крейты обычно скачиваются из централизованного репозитория под названием
crates.io. - Инструмент
cargoавтоматически загружает крейты и их зависимости. Концептуально это эквивалентно подключению C-библиотек - Зависимости крейта описываются в файле
Cargo.toml. Там же задаётся тип цели для крейта: самостоятельный исполняемый файл, статическая библиотека, динамическая библиотека (встречается редко) - Справка: https://doc.rust-lang.org/cargo/reference/cargo-targets.html
- Крейт может быть самостоятельным или зависеть от других крейтов. Крейты-зависимости могут быть локальными или удалёнными. Сторонние крейты обычно скачиваются из централизованного репозитория под названием
Cargo и традиционные системы сборки C
Сравнение управления зависимостями
graph TD
subgraph "Традиционный процесс сборки на C"
CC["Исходные файлы C<br/>(.c, .h)"]
CM["Ручной Makefile<br/>или CMake"]
CL["Компоновщик"]
CB["Итоговый бинарный файл"]
CC --> CM
CM --> CL
CL --> CB
CDep["Ручное управление<br/>зависимостями"]
CLib1["libcurl-dev<br/>(apt install)"]
CLib2["libjson-dev<br/>(apt install)"]
CInc["Ручные пути включения<br/>-I/usr/include/curl"]
CLink["Ручная компоновка<br/>-lcurl -ljson"]
CDep --> CLib1
CDep --> CLib2
CLib1 --> CInc
CLib2 --> CInc
CInc --> CM
CLink --> CL
C_ISSUES["[ОШИБКА] Конфликты версий<br/>[ОШИБКА] Различия платформ<br/>[ОШИБКА] Отсутствующие зависимости<br/>[ОШИБКА] Важен порядок компоновки<br/>[ОШИБКА] Нет автоматических обновлений"]
end
subgraph "Процесс сборки Rust с Cargo"
RS["Исходные файлы Rust<br/>(.rs)"]
CT["Cargo.toml<br/>[dependencies]<br/>reqwest = '0.11'<br/>serde_json = '1.0'"]
CRG["Система сборки Cargo"]
RB["Итоговый бинарный файл"]
RS --> CRG
CT --> CRG
CRG --> RB
CRATES["crates.io<br/>(реестр пакетов)"]
DEPS["Автоматическое разрешение<br/>зависимостей"]
LOCK["Cargo.lock<br/>(фиксация версий)"]
CRATES --> DEPS
DEPS --> CRG
CRG --> LOCK
R_BENEFITS["[OK] Семантическое версионирование<br/>[OK] Автоматическая загрузка<br/>[OK] Кроссплатформенность<br/>[OK] Транзитивные зависимости<br/>[OK] Воспроизводимые сборки"]
end
style C_ISSUES fill:#ff6b6b,color:#000
style R_BENEFITS fill:#91e5a3,color:#000
style CM fill:#ffa07a,color:#000
style CDep fill:#ffa07a,color:#000
style CT fill:#91e5a3,color:#000
style CRG fill:#91e5a3,color:#000
style DEPS fill:#91e5a3,color:#000
style CRATES fill:#91e5a3,color:#000
Структура проекта Cargo
my_project/
|-- Cargo.toml # Конфигурация проекта (аналог package.json)
|-- Cargo.lock # Точные версии зависимостей (генерируется автоматически)
|-- src/
| |-- main.rs # Главная точка входа для бинарного файла
| |-- lib.rs # Корень библиотеки (если создаёте библиотеку)
| `-- bin/ # Дополнительные бинарные цели
|-- tests/ # Интеграционные тесты
|-- examples/ # Примеры кода
|-- benches/ # Бенчмарки
`-- target/ # Артефакты сборки (аналог build/ или obj/ в C)
|-- debug/ # Отладочные сборки (быстрая компиляция, медленное выполнение)
`-- release/ # Релизные сборки (медленная компиляция, быстрое выполнение)
Основные команды Cargo
graph LR
subgraph "Жизненный цикл проекта"
NEW["cargo new my_project<br/>[FOLDER] Создать проект"]
CHECK["cargo check<br/>[SEARCH] Быстрая проверка синтаксиса"]
BUILD["cargo build<br/>[BUILD] Скомпилировать проект"]
RUN["cargo run<br/>[PLAY] Собрать и выполнить"]
TEST["cargo test<br/>[TEST] Запустить все тесты"]
NEW --> CHECK
CHECK --> BUILD
BUILD --> RUN
BUILD --> TEST
end
subgraph "Дополнительные команды"
UPDATE["cargo update<br/>[CHART] Обновить зависимости"]
FORMAT["cargo fmt<br/>[SPARKLES] Форматировать код"]
LINT["cargo clippy<br/>[WRENCH] Линтинг и подсказки"]
DOC["cargo doc<br/>[BOOKS] Сгенерировать документацию"]
PUBLISH["cargo publish<br/>[PACKAGE] Опубликовать на crates.io"]
end
subgraph "Профили сборки"
DEBUG["cargo build<br/>(отладочный профиль)<br/>Быстрая компиляция<br/>Медленное выполнение<br/>Отладочные символы"]
RELEASE["cargo build --release<br/>(релизный профиль)<br/>Медленная компиляция<br/>Быстрое выполнение<br/>Оптимизирован"]
end
style NEW fill:#a3d5ff,color:#000
style CHECK fill:#91e5a3,color:#000
style BUILD fill:#ffa07a,color:#000
style RUN fill:#ffcc5c,color:#000
style TEST fill:#c084fc,color:#000
style DEBUG fill:#94a3b8,color:#000
style RELEASE fill:#ef4444,color:#000
Пример: cargo и крейты
- В этом примере у нас есть самостоятельный исполняемый крейт без других зависимостей
- Используйте следующие команды, чтобы создать новый крейт под названием
helloworld
cargo new helloworld
cd helloworld
cat Cargo.toml
- По умолчанию
cargo runскомпилирует и запуститdebug-версию крейта (без оптимизаций). Чтобы запуститьrelease-версию, используйтеcargo run --release - Обратите внимание: сам бинарный файл находится в папке
target, внутри папкиdebugилиrelease - Возможно, вы также заметили файл
Cargo.lockрядом с исходниками. Он генерируется автоматически и не должен редактироваться вручную- Конкретное назначение
Cargo.lockмы рассмотрим позже
- Конкретное назначение
Встроенные типы Rust
Что вы узнаете: фундаментальные типы Rust (
i32,u64,f64,bool,char), вывод типов, явные аннотации типов и то, как они соотносятся с примитивными типами C/C++. Неявных преобразований нет — Rust требует явных приведений.
- В Rust есть вывод типов, но допускается и явное указание типа
| Описание | Тип | Пример |
|---|---|---|
| Знаковые целые | i8, i16, i32, i64, i128, isize | -1, 42, 1_00_000, 1_00_000i64 |
| Беззнаковые целые | u8, u16, u32, u64, u128, usize | 0, 42, 42u32, 42u64 |
| Числа с плавающей точкой | f32, f64 | 0.0, 0.42 |
| Unicode | char | ‘a’, ‘$’ |
| Логический | bool | true, false |
- Rust допускает произвольное использование
_между цифрами для удобства чтения
Указание типа и присваивание в Rust
- Для присваивания значений переменным в Rust используется ключевое слово
let. Тип переменной можно необязательно указать после:
fn main() {
let x : i32 = 42;
// Эти два присваивания логически эквивалентны
let y : u32 = 42;
let z = 42u32;
}
- Параметры функций и возвращаемые значения (если есть) требуют явного указания типа. Следующая функция принимает параметр u8 и возвращает u32
#![allow(unused)]
fn main() {
fn foo(x : u8) -> u32
{
return x as u32 * x as u32;
}
}
- Неиспользуемые переменные помечаются префиксом
_, чтобы не получать предупреждения компилятора
Указание типа и вывод типа в Rust
- Rust может автоматически вывести тип переменной из контекста.
- ▶ Попробуйте в Rust Playground
fn secret_of_life_u32(x : u32) {
println!("The u32 secret_of_life is {}", x);
}
fn secret_of_life_u8(x : u8) {
println!("The u8 secret_of_life is {}", x);
}
fn main() {
let a = 42; // Ключевое слово let присваивает значение; тип a — u32
let b = 42; // Ключевое слово let присваивает значение; выведенный тип b — u8
secret_of_life_u32(a);
secret_of_life_u8(b);
}
Переменные и изменяемость в Rust
- Переменные в Rust по умолчанию неизменяемые, если не использовано ключевое слово
mut, которое обозначает изменяемость переменной. Например, следующий код не скомпилируется, покаlet a = 42не будет заменено наlet mut a = 42
fn main() {
let a = 42; // Нужно заменить на let mut a = 42, чтобы разрешить присваивание ниже
a = 43; // Не скомпилируется, пока выше не внесено изменение
}
- Rust допускает повторное использование имён переменных (затенение, shadowing)
fn main() {
let a = 42;
{
let a = 43; //OK: Другая переменная с тем же именем
}
// a = 43; // Не допускается
let a = 43; // Ок: новая переменная и присваивание
}
Ключевое слово if в Rust
Что вы узнаете: конструкции управления потоком выполнения в Rust —
if/elseкак выражения,loop/while/for,matchи то, чем они отличаются от аналогов в C/C++. Ключевая мысль: большинство конструкций управления потоком в Rust возвращают значения.
- В Rust
if— это выражение, то есть его можно использовать для присваивания значений, но он также ведёт себя как оператор. ▶ Попробовать
fn main() {
let x = 42;
if x < 42 {
println!("Smaller than the secret of life");
} else if x == 42 {
println!("Is equal to the secret of life");
} else {
println!("Larger than the secret of life");
}
let is_secret_of_life = if x == 42 {true} else {false};
println!("{}", is_secret_of_life);
}
Циклы while и for в Rust
- Ключевое слово
whileможно использовать для цикла, пока выражение истинно
fn main() {
let mut x = 40;
while x != 42 {
x += 1;
}
}
- Ключевое слово
forможно использовать для итерации по диапазонам
fn main() {
// Не выведет 43; используйте 40..=43, чтобы включить последний элемент
for x in 40..43 {
println!("{}", x);
}
}
Циклы loop в Rust
- Ключевое слово
loopсоздаёт бесконечный цикл, пока не встретитсяbreak
fn main() {
let mut x = 40;
// Замените на 'here: loop, чтобы указать необязательную метку цикла
loop {
if x == 42 {
break; // Используйте break x; чтобы вернуть значение x
}
x += 1;
}
}
- Оператор
breakможет включать необязательное выражение, которое используется для присваивания значения выражениюloop - Ключевое слово
continueвозвращает выполнение к началуloop - Метки циклов можно использовать с
breakилиcontinue; они полезны при работе с вложенными циклами
Блоки-выражения в Rust
- Блок-выражение в Rust — это просто последовательность выражений в
{}. Значением блока становится последнее выражение в нём
fn main() {
let x = {
let y = 40;
y + 2 // Примечание: ; здесь не ставится
};
// Обратите внимание на вывод в стиле Python
println!("{x}");
}
- В стиле Rust принято опускать ключевое слово
returnв функциях, используя это правило
fn is_secret_of_life(x: u32) -> bool {
// То же, что if x == 42 {true} else {false}
x == 42 // Примечание: ; здесь не ставится
}
fn main() {
println!("{}", is_secret_of_life(42));
}
5. Структуры данных
Массивы в Rust
Что вы узнаете: основные структуры данных Rust — массивы, кортежи, срезы, строки, структуры,
VecиHashMap. Глава плотная; сосредоточьтесь на пониманииStringи&strи на том, как работают структуры. Ссылки и заимствования вы разберёте подробно в главе 7.
- Массивы содержат фиксированное количество элементов одного типа
- Как и все остальные типы Rust, массивы по умолчанию неизменяемые (если не используется mut)
- Доступ к элементам массива осуществляется через [] и проверяется на выход за границы. Длину массива можно получить методом len()
fn get_index(y : usize) -> usize {
y+1
}
fn main() {
// Инициализирует массив из 3 элементов и задаёт всем значение 42
let a : [u8; 3] = [42; 3];
// Альтернативный синтаксис
// let a = [42u8, 42u8, 42u8];
for x in a {
println!("{x}");
}
let y = get_index(a.len());
// Если раскомментировать строку ниже, произойдёт panic
//println!("{}", a[y]);
}
Массивы в Rust (продолжение)
- Массивы можно вкладывать друг в друга
- В Rust есть несколько встроенных форматировщиков для вывода. В примере ниже
:?— это форматировщикdebug. Форматировщик:#?используется дляpretty print(красивого вывода). Эти форматировщики можно настроить для каждого типа (подробнее об этом позже)
- В Rust есть несколько встроенных форматировщиков для вывода. В примере ниже
fn main() {
let a = [
[40, 0], // Определяем вложенный массив
[41, 0],
[42, 1],
];
for x in a {
println!("{x:?}");
}
}
Кортежи в Rust
- Кортежи имеют фиксированный размер и могут объединять значения произвольных типов в один составной тип
- Элементы кортежа индексируются по их позиции (.0, .1, .2, …). Пустой кортеж, то есть (), называется значением unit и соответствует возврату void
- Rust поддерживает деструктуризацию кортежей, что упрощает привязку переменных к отдельным элементам
fn get_tuple() -> (u32, bool) {
(42, true)
}
fn main() {
let t : (u8, bool) = (42, true);
let u : (u32, bool) = (43, false);
println!("{}, {}", t.0, t.1);
println!("{}, {}", u.0, u.1);
let (num, flag) = get_tuple(); // Деструктуризация кортежа
println!("{num}, {flag}");
}
Ссылки в Rust
- Ссылки в Rust примерно соответствуют указателям в C, но с некоторыми ключевыми отличиями
- В любой момент времени допустимо иметь любое количество ссылок только для чтения (неизменяемых) на переменную. Ссылка не может пережить область видимости переменной (это ключевое понятие под названием время жизни, которое подробно рассматривается дальше)
- Для изменяемой переменной допускается только одна изменяемая ссылка для записи, и она не должна пересекаться ни с какими другими ссылками.
fn main() {
let mut a = 42;
{
let b = &a;
let c = b;
println!("{} {}", *b, *c); // Компилятор автоматически разыменовывает *c
let d = &mut a;
/*
* Если раскомментировать строку ниже, программа
* не скомпилируется, потому что `b` используется,
* пока изменяемая ссылка `d` активна в текущей области
*
* Нельзя одновременно использовать изменяемую и неизменяемую ссылку
* в одной области видимости!
*/
// println!("{}", *b);
}
let d = &mut a; // Ок: b и c уже вне области видимости
*d = 43;
}
Срезы в Rust
- Ссылки в Rust можно использовать для создания подмножеств массивов
- В отличие от массивов, которые имеют статическую фиксированную длину, известную на этапе компиляции, срезы могут иметь произвольный размер. Внутри срез реализован как «толстый указатель» (fat pointer), который хранит длину среза и указатель на начальный элемент исходного массива
fn main() {
let a = [40, 41, 42, 43];
let b = &a[1..a.len()]; // Срез, начинающийся со второго элемента исходного массива
let c = &a[1..]; // То же, что и выше
let d = &a[..]; // То же, что &a[0..] или &a[0..a.len()]
println!("{b:?} {c:?} {d:?}");
}
Константы и статики в Rust
- Ключевое слово
constиспользуется для определения константного значения. Константы вычисляются на этапе компиляции и встраиваются в программу - Ключевое слово
staticиспользуется для определения аналога глобальных переменных из языков вроде C/C++. Статические переменные имеют адрес в памяти, создаются один раз и существуют на протяжении всего времени работы программы
const SECRET_OF_LIFE: u32 = 42;
static GLOBAL_VARIABLE : u32 = 2;
fn main() {
println!("The secret of life is {}", SECRET_OF_LIFE);
println!("Value of global variable is {GLOBAL_VARIABLE}")
}
Строки в Rust: String и &str
- В Rust два строковых типа, которые служат разным целям
String— владеющий, размещённый в куче, растущий (как буфер, выделенный черезmallocв C, илиstd::stringв C++)&str— заимствованная лёгкая ссылка (какconst char*с длиной в C илиstd::string_viewв C++; но&strпроверяется по времени жизни, поэтому никогда не может стать висячим)- В отличие от строк с нулевым символом в конце, как в C, строки Rust хранят свою длину и гарантированно являются корректным UTF-8
Для программистов C++:
String≈std::string,&str≈std::string_view. В отличие отstd::string_view, проверка заимствований гарантирует, что&strдействителен на протяжении всего своего времени жизни.
String и &str: владение и заимствование
Промышленные шаблоны: о том, как работа со строками выглядит вместе с serde в продакшн-коде, см. Обработка JSON: nlohmann::json → serde.
| Аспект | C char* | C++ std::string | Rust String | Rust &str |
|---|---|---|---|---|
| Память | Вручную (malloc/free) | В куче, владеет буфером | В куче, освобождается автоматически | Заимствованная ссылка (проверяется по времени жизни) |
| Изменяемость | Всегда изменяема через указатель | Изменяема | Изменяема при наличии mut | Всегда неизменяема |
| Информация о размере | Отсутствует (полагается на '\0') | Хранит длину и ёмкость | Хранит длину и ёмкость | Хранит длину (толстый указатель) |
| Кодировка | Не определена (обычно ASCII) | Не определена (обычно ASCII) | Гарантированно корректный UTF-8 | Гарантированно корректный UTF-8 |
| Завершающий нуль | Обязателен | Обязателен (c_str()) | Не используется | Не используется |
fn main() {
// &str — строковый срез (заимствованный, неизменяемый, обычно строковый литерал)
let greeting: &str = "Hello"; // Указывает на память только для чтения
// String — владеющая, размещённая в куче, растущая строка
let mut owned = String::from(greeting); // Копирует данные в кучу
owned.push_str(", World!"); // Увеличиваем строку
owned.push('!'); // Добавляем один символ
// Преобразования между String и &str
let slice: &str = &owned; // String -> &str (бесплатно, это просто заимствование)
let owned2: String = slice.to_string(); // &str -> String (выделяет память)
let owned3: String = String::from(slice); // То же, что и выше
// Конкатенация строк (обратите внимание: + поглощает левый операнд)
let hello = String::from("Hello");
let world = String::from(", World!");
let combined = hello + &world; // hello перемещён (поглощён), world заимствован
// println!("{hello}"); // Не скомпилируется: hello был перемещён
// Используйте format!, чтобы избежать проблем с перемещением
let a = String::from("Hello");
let b = String::from("World");
let combined = format!("{a}, {b}!"); // Ни a, ни b не поглощаются
println!("{combined}");
}
Почему нельзя индексировать строки через []
fn main() {
let s = String::from("hello");
// let c = s[0]; // Не скомпилируется! Строки Rust — это UTF-8, а не массивы байтов
// Безопасные альтернативы:
let first_char = s.chars().next(); // Option<char>: Some('h')
let as_bytes = s.as_bytes(); // &[u8]: сырые байты UTF-8
let substring = &s[0..1]; // &str: "h" (диапазон байтов, должен попадать на границу UTF-8)
println!("First char: {:?}", first_char);
println!("Bytes: {:?}", &as_bytes[..5]);
}
Упражнение: работа со строками
🟢 Начальный уровень
- Напишите функцию
fn count_words(text: &str) -> usize, которая считает количество слов, разделённых пробельными символами, в строке - Напишите функцию
fn longest_word(text: &str) -> &str, которая возвращает самое длинное слово (подсказка: придётся подумать о временах жизни — почему возвращаемый тип должен быть&str, а неString?)
Решение (нажмите, чтобы развернуть)
fn count_words(text: &str) -> usize {
text.split_whitespace().count()
}
fn longest_word(text: &str) -> &str {
text.split_whitespace()
.max_by_key(|word| word.len())
.unwrap_or("")
}
fn main() {
let text = "the quick brown fox jumps over the lazy dog";
println!("Word count: {}", count_words(text)); // 9
println!("Longest word: {}", longest_word(text)); // "jumps"
}
Структуры в Rust
- Ключевое слово
structобъявляет пользовательский тип структуры- Члены
structмогут быть именованными или безымянными (кортежные структуры)
- Члены
- В отличие от языков вроде C++, в Rust нет понятия «наследования данных»
fn main() {
struct MyStruct {
num: u32,
is_secret_of_life: bool,
}
let x = MyStruct {
num: 42,
is_secret_of_life: true,
};
let y = MyStruct {
num: x.num,
is_secret_of_life: x.is_secret_of_life,
};
let z = MyStruct { num: x.num, ..x }; // Оператор .. означает: скопировать остальные поля
println!("{} {} {}", x.num, y.is_secret_of_life, z.num);
}
Кортежные структуры в Rust
- Кортежные структуры в Rust похожи на кортежи, а их поля не имеют имён
- Как и в кортежах, к отдельным элементам обращаются через .0, .1, .2, …. Типичный случай использования кортежных структур — оборачивание примитивных типов для создания собственных типов. Это может быть полезно, чтобы не перепутать значения одного и того же типа
struct WeightInGrams(u32);
struct WeightInMilligrams(u32);
fn to_weight_in_grams(kilograms: u32) -> WeightInGrams {
WeightInGrams(kilograms * 1000)
}
fn to_weight_in_milligrams(w : WeightInGrams) -> WeightInMilligrams {
WeightInMilligrams(w.0 * 1000)
}
fn main() {
let x = to_weight_in_grams(42);
let y = to_weight_in_milligrams(x);
// let z : WeightInGrams = x; // Не скомпилируется: x был перемещён в to_weight_in_milligrams()
// let a : WeightInGrams = y; // Не скомпилируется: несоответствие типов (WeightInMilligrams и WeightInGrams)
}
Примечание: атрибут #[derive(...)] автоматически генерирует реализации распространённых трейтов для структур и перечислений. Вы будете встречать его на протяжении всего курса:
#[derive(Debug, Clone, PartialEq)]
struct Point { x: i32, y: i32 }
fn main() {
let p = Point { x: 1, y: 2 };
println!("{:?}", p); // Debug: работает благодаря #[derive(Debug)]
let p2 = p.clone(); // Clone: работает благодаря #[derive(Clone)]
assert_eq!(p, p2); // PartialEq: работает благодаря #[derive(PartialEq)]
}
Трейтовую систему мы разберём подробно позже, но #[derive(Debug)] настолько полезен, что его стоит добавлять почти к каждой создаваемой вами struct и enum.
Тип Vec в Rust
- Тип
Vec<T>реализует динамический буфер, размещённый в куче (похоже на вручную управляемые массивы черезmalloc/reallocв C или наstd::vectorв C++)- В отличие от массивов фиксированного размера,
Vecможет расти и уменьшаться во время выполнения Vecвладеет своими данными и автоматически управляет выделением и освобождением памяти
- В отличие от массивов фиксированного размера,
- Распространённые операции:
push(),pop(),insert(),remove(),len(),capacity()
fn main() {
let mut v = Vec::new(); // Пустой вектор, тип выводится из использования
v.push(42); // Добавляем элемент в конец — Vec<i32>
v.push(43);
// Безопасная итерация (предпочтительно)
for x in &v { // Заимствуем элементы, не поглощая вектор
println!("{x}");
}
// Сокращённые способы инициализации
let mut v2 = vec![1, 2, 3, 4, 5]; // Макрос для инициализации
let v3 = vec![0; 10]; // 10 нулей
// Безопасные методы доступа (предпочтительнее индексации)
match v2.get(0) {
Some(first) => println!("First: {first}"),
None => println!("Empty vector"),
}
// Полезные методы
println!("Length: {}, Capacity: {}", v2.len(), v2.capacity());
if let Some(last) = v2.pop() { // Удаляем и возвращаем последний элемент
println!("Popped: {last}");
}
// Опасно: прямая индексация (может вызвать panic!)
// println!("{}", v2[100]); // Во время выполнения произойдёт panic
}
Промышленные шаблоны: о безопасных шаблонах
.get()из продакшн-кода на Rust см. Избегание неконтролируемой индексации.
Тип HashMap в Rust
HashMapреализует поиск по обобщённомуключу->значению(также известен какdictionaryилиmap)
fn main() {
use std::collections::HashMap; // Нужен явный импорт, в отличие от Vec
let mut map = HashMap::new(); // Выделяем пустой HashMap
map.insert(40, false); // Тип выводится как int -> bool
map.insert(41, false);
map.insert(42, true);
for (key, value) in map {
println!("{key} {value}");
}
let map = HashMap::from([(40, false), (41, false), (42, true)]);
if let Some(x) = map.get(&43) {
println!("43 was mapped to {x:?}");
} else {
println!("No mapping was found for 43");
}
let x = map.get(&43).or(Some(&false)); // Значение по умолчанию, если ключ не найден
println!("{x:?}");
}
Упражнение: Vec и HashMap
🟢 Начальный уровень
- Создайте
HashMap<u32, bool>с несколькими записями (убедитесь, что одни значения равныtrue, а другие —false). Переберите все элементы хеш-таблицы, поместив ключи в одинVec, а значения — в другой
Решение (нажмите, чтобы развернуть)
use std::collections::HashMap;
fn main() {
let map = HashMap::from([(1, true), (2, false), (3, true), (4, false)]);
let mut keys = Vec::new();
let mut values = Vec::new();
for (k, v) in &map {
keys.push(*k);
values.push(*v);
}
println!("Keys: {keys:?}");
println!("Values: {values:?}");
// Альтернатива: использовать итераторы с unzip()
let (keys2, values2): (Vec<u32>, Vec<bool>) = map.into_iter().unzip();
println!("Keys (unzip): {keys2:?}");
println!("Values (unzip): {values2:?}");
}
Углублённый разбор: ссылки C++ и ссылки Rust
Для программистов C++: программисты C++ часто предполагают, что
&Tв Rust работает так же, какT&в C++. Хотя внешне они похожи, есть принципиальные различия, которые порождают путаницу. Разработчики на C могут пропустить этот раздел — ссылки в Rust рассматриваются в главе Владение и заимствование.
1. Нет rvalue-ссылок и универсальных ссылок
В C++ && имеет два значения в зависимости от контекста:
// C++: && означает разные вещи:
int&& rref = 42; // Rvalue-ссылка — связывается с временными объектами
void process(Widget&& w); // Rvalue-ссылка — вызывающий код должен использовать std::move
// Универсальная (forwarding) ссылка — выводится в шаблонном контексте:
template<typename T>
void forward(T&& arg) { // НЕ rvalue-ссылка! Выводится как T& или T&&
inner(std::forward<T>(arg)); // Идеальная передача
}
В Rust ничего этого нет. && — это просто оператор логического И.
#![allow(unused)]
fn main() {
// Rust: && — это просто булево И
let a = true && false; // false
// В Rust НЕТ rvalue-ссылок, универсальных ссылок и идеальной передачи.
// Вместо этого:
// - Перемещение — поведение по умолчанию для типов, не реализующих Copy (std::move не нужен)
// - Обобщения + ограничения трейтами заменяют универсальные ссылки
// - Нет различия между временными и постоянными привязками — значения есть значения
fn process(w: Widget) { } // Принимает владение (как параметр-значение в C++ + неявное перемещение)
fn process_ref(w: &Widget) { } // Заимствует неизменяемо (как const T& в C++)
fn process_mut(w: &mut Widget) { } // Заимствует изменяемо (как T& в C++, но эксклюзивно)
}
| Концепция C++ | Эквивалент в Rust | Примечания |
|---|---|---|
T& (ссылка на lvalue) | &T или &mut T | Rust разделяет на общие и эксклюзивные |
T&& (rvalue-ссылка) | Просто T | Передача по значению = передача владения |
T&& в шаблоне (универсальная ссылка) | impl Trait или <T: Trait> | Обобщения заменяют передачу |
std::move(x) | x (просто используйте) | Перемещение — поведение по умолчанию |
std::forward<T>(x) | Эквивалент не нужен | Нет универсальных ссылок, которые нужно передавать дальше |
2. Перемещения — побитовые, конструкторов перемещения нет
В C++ перемещение — это пользовательская операция (конструктор перемещения / оператор присваивания перемещением). В Rust перемещение всегда является побитовым memcpy значения, а исходный объект становится недействительным:
#![allow(unused)]
fn main() {
// Перемещение в Rust = memcpy байтов, исходное значение помечается как недействительное
let s1 = String::from("hello");
let s2 = s1; // Байты s1 копируются в слот стека s2
// s1 теперь недействителен — компилятор это обеспечивает
// println!("{s1}"); // ❌ Ошибка компиляции: значение использовано после перемещения
}
// Перемещение в C++ = вызов конструктора перемещения (пользовательского!)
std::string s1 = "hello";
std::string s2 = std::move(s1); // Вызывается конструктор перемещения string
// s1 теперь «зомби» в состоянии «допустимо, но не определено»
std::cout << s1; // Компилируется! Выводит... что-то (обычно пустую строку)
Следствия:
- В Rust нет правила пяти (не нужно определять конструктор копирования, конструктор перемещения, оператор копирующего присваивания, оператор перемещающего присваивания и деструктор)
- Нет состояния «зомби» после перемещения — компилятор просто запрещает доступ
- Нет вопросов
noexceptдля перемещений — побитовое копирование не может выбросить исключение
3. Автоматическое разыменование: компилятор видит сквозь косвенность
Rust автоматически разыменовывает через несколько слоёв указателей и оберток с помощью трейта Deref. В C++ аналога нет:
#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex};
// Вложенные обёртки: Arc<Mutex<Vec<String>>>
let data = Arc::new(Mutex::new(vec!["hello".to_string()]));
// В C++ пришлось бы явно разблокировать и вручную разыменовывать на каждом уровне.
// В Rust компилятор автоматически разыменовывает через Arc → Mutex → MutexGuard → Vec:
let guard = data.lock().unwrap(); // Arc автоматически разыменовывается в Mutex
let first: &str = &guard[0]; // MutexGuard→Vec (Deref), Vec[0] (Index),
// &String→&str (приведение через Deref)
println!("First: {first}");
// Вызовы методов тоже автоматически разыменовываются:
let boxed_string = Box::new(String::from("hello"));
println!("Length: {}", boxed_string.len()); // Box→String, затем String::len()
// Не нужно (*boxed_string).len() или boxed_string->len()
}
Приведение через Deref работает и для аргументов функций — компилятор вставляет разыменования, чтобы типы совпали:
fn greet(name: &str) {
println!("Hello, {name}");
}
fn main() {
let owned = String::from("Alice");
let boxed = Box::new(String::from("Bob"));
let arced = std::sync::Arc::new(String::from("Carol"));
greet(&owned); // &String → &str (1 приведение через Deref)
greet(&boxed); // &Box<String> → &String → &str (2 приведения через Deref)
greet(&arced); // &Arc<String> → &String → &str (2 приведения через Deref)
greet("Dave"); // Уже &str — приведение не нужно
}
// В C++ для каждого случая потребовались бы .c_str() или явные преобразования.
Цепочка Deref: когда вы вызываете x.method(), разрешение метода в Rust
сначала пробует тип получателя T, затем &T, затем &mut T. Если совпадения
нет, выполняется разыменование через трейт Deref, и процесс повторяется для целевого типа.
Это продолжается через несколько слоёв — поэтому Box<Vec<T>>
«просто работает» как Vec<T>. Приведение через Deref (для аргументов функций)
— отдельный, но связанный механизм, который автоматически превращает &Box<String>
в &str, цепочкой реализаций Deref.
4. Нет нулевых ссылок и нет «опциональных» ссылок
// C++: ссылки не могут быть нулевыми, но указатели могут, и грань между ними размыта
Widget& ref = *ptr; // Если ptr нулевой → UB
Widget* opt = nullptr; // «Опциональная» ссылка через указатель
#![allow(unused)]
fn main() {
// Rust: ссылки ВСЕГДА валидны — гарантируется проверкой заимствований
// В безопасном коде нельзя создать нулевую или висячую ссылку
let r: &i32 = &42; // Всегда валидна
// «Опциональная ссылка» задаётся явно:
let opt: Option<&Widget> = None; // Намерение очевидно, нулевого указателя нет
if let Some(w) = opt {
w.do_something(); // Достижимо только при наличии значения
}
}
5. Ссылки нельзя переназначить
// C++: ссылка — это псевдоним, её нельзя перепривязать
int a = 1, b = 2;
int& r = a;
r = b; // Это ПРИСВАИВАЕТ значение b переменной a — НЕ перепривязывает r!
// Теперь a равно 2, r по-прежнему ссылается на a
#![allow(unused)]
fn main() {
// Rust: привязки let могут затенять друг друга, но у ссылок свои правила
let a = 1;
let b = 2;
let r = &a;
// r = &b; // ❌ Нельзя присвоить неизменяемой переменной
let r = &b; // ✅ Но можно ЗАТЕНИТЬ r новой привязкой
// Старая привязка исчезает, а не перепривязывается
// С mut:
let mut r = &a;
r = &b; // ✅ Теперь r указывает на b — это перепривязка (а не присваивание через ссылку)
}
Ментальная модель: в C++ ссылка — это постоянный псевдоним для одного объекта. В Rust ссылка — это значение (указатель с гарантиями времени жизни), которое подчиняется обычным правилам привязки переменных: неизменяемая по умолчанию, перепривязываемая только если объявлена с
mut.
Перечисления в Rust
Что вы узнаете: перечисления Rust как размеченные объединения (tagged unions, сделанные правильно),
matchдля исчерпывающего сопоставления с образцом и то, как перечисления заменяют иерархии классов C++ и размеченные объединения C с безопасностью, которую обеспечивает компилятор.
- Перечисления в Rust — это размеченные объединения, то есть суммовые типы из нескольких возможных различных типов с меткой, которая определяет конкретный вариант
- Для разработчиков на C: перечисления в Rust могут содержать данные (размеченные объединения, сделанные правильно — компилятор отслеживает, какой вариант активен)
- Для разработчиков на C++: перечисления Rust похожи на
std::variant, но с исчерпывающим сопоставлением с образцом, без исключенийstd::getи без шаблонного кодаstd::visit - Размер
enumравен размеру самого большого возможного варианта. Отдельные варианты не связаны друг с другом и могут иметь совершенно разные типы - Типы
enum— одна из самых мощных возможностей языка: они заменяют целые иерархии классов в C++ (подробнее об этом в разборах кейсов)
fn main() {
enum Numbers {
Zero,
SmallNumber(u8),
BiggerNumber(u32),
EvenBiggerNumber(u64),
}
let a = Numbers::Zero;
let b = Numbers::SmallNumber(42);
let c : Numbers = a; // Ок — тип a это Numbers
let d : Numbers = b; // Ок — тип b это Numbers
}
Оператор match в Rust
matchв Rust — это аналог C-шного “switch”, но на стероидахmatchможно использовать для сопоставления с образцом простых типов данных,struct,enum- Оператор
matchдолжен быть исчерпывающим, то есть покрывать все возможные случаи для данноготипа. Символ_используется как подстановочный шаблон для случая «все остальные» matchможет возвращать значение, но все ветки (=>) должны возвращать значение одного и того же типа
fn main() {
let x = 42;
// В этом случае _ покрывает все числа, кроме явно перечисленных
let is_secret_of_life = match x {
42 => true, // Возвращаемый тип — булево значение
_ => false, // Возвращаемый тип — булево значение
// Это не скомпилируется, потому что возвращаемый тип не булев
// _ => 0
};
println!("{is_secret_of_life}");
}
Оператор match в Rust
matchподдерживает диапазоны, булевы фильтры и охранные условияif
fn main() {
let x = 42;
match x {
// Обратите внимание: =41 обеспечивает включение верхней границы диапазона
0..=41 => println!("Less than the secret of life"),
42 => println!("Secret of life"),
_ => println!("More than the secret of life"),
}
let y = 100;
match y {
100 if x == 43 => println!("y is 100% not secret of life"),
100 if x == 42 => println!("y is 100% secret of life"),
_ => (), // Ничего не делать
}
}
Оператор match в Rust
matchиenumчасто используются вместе- Оператор match может «связать» содержащееся значение с переменной. Используйте
_, если значение не важно - Макрос
matches!можно использовать для проверки на конкретный вариант
- Оператор match может «связать» содержащееся значение с переменной. Используйте
fn main() {
enum Numbers {
Zero,
SmallNumber(u8),
BiggerNumber(u32),
EvenBiggerNumber(u64),
}
let b = Numbers::SmallNumber(42);
match b {
Numbers::Zero => println!("Zero"),
Numbers::SmallNumber(value) => println!("Small number {value}"),
Numbers::BiggerNumber(_) | Numbers::EvenBiggerNumber(_) => println!("Some BiggerNumber or EvenBiggerNumber"),
}
// Булева проверка на конкретные варианты
if matches!(b, Numbers::Zero | Numbers::SmallNumber(_)) {
println!("Matched Zero or small number");
}
}
Оператор match в Rust
matchтакже может выполнять сопоставление с помощью деструктуризации и срезов
fn main() {
struct Foo {
x: (u32, bool),
y: u32
}
let f = Foo {x: (42, true), y: 100};
match f {
// Захватываем значение x в переменную tuple
Foo{y: 100, x : tuple} => println!("Matched x: {tuple:?}"),
_ => ()
}
let a = [40, 41, 42];
match a {
// Последний элемент среза должен быть 42. @ используется для связывания с образцом
[rest @ .., 42] => println!("{rest:?}"),
// Первый элемент среза должен быть 42. @ используется для связывания с образцом
[42, rest @ ..] => println!("{rest:?}"),
_ => (),
}
}
Упражнение: сложение и вычитание с помощью match и enum
🟢 Начальный уровень
- Напишите функцию, которая реализует арифметические операции над беззнаковыми 64-битными числами
- Шаг 1: определите перечисление для операций:
#![allow(unused)]
fn main() {
enum Operation {
Add(u64, u64),
Subtract(u64, u64),
}
}
- Шаг 2: определите перечисление результата:
#![allow(unused)]
fn main() {
enum CalcResult {
Ok(u64), // Успешный результат
Invalid(String), // Сообщение об ошибке для недопустимых операций
}
}
- Шаг 3: реализуйте
calculate(op: Operation) -> CalcResult- Для Add: вернуть Ok(sum)
- Для Subtract: вернуть Ok(difference), если первое число >= второго, иначе Invalid(“Underflow”)
- Подсказка: используйте сопоставление с образцом в функции:
#![allow(unused)]
fn main() {
match op {
Operation::Add(a, b) => { /* ваш код */ },
Operation::Subtract(a, b) => { /* ваш код */ },
}
}
Решение (нажмите, чтобы развернуть)
enum Operation {
Add(u64, u64),
Subtract(u64, u64),
}
enum CalcResult {
Ok(u64),
Invalid(String),
}
fn calculate(op: Operation) -> CalcResult {
match op {
Operation::Add(a, b) => CalcResult::Ok(a + b),
Operation::Subtract(a, b) => {
if a >= b {
CalcResult::Ok(a - b)
} else {
CalcResult::Invalid("Underflow".to_string())
}
}
}
}
fn main() {
match calculate(Operation::Add(10, 20)) {
CalcResult::Ok(result) => println!("10 + 20 = {result}"),
CalcResult::Invalid(msg) => println!("Error: {msg}"),
}
match calculate(Operation::Subtract(5, 10)) {
CalcResult::Ok(result) => println!("5 - 10 = {result}"),
CalcResult::Invalid(msg) => println!("Error: {msg}"),
}
}
// Вывод:
// 10 + 20 = 30
// Error: Underflow
Ассоциированные методы в Rust
implможет определять методы, ассоциированные с типами вродеstruct,enumи т. д.- Методы могут необязательно принимать
selfкак параметр.selfконцептуально похож на передачу указателя на структуру первым параметром в C или наthisв C++ - Ссылка на
selfможет быть неизменяемой (по умолчанию:&self), изменяемой (&mut self) илиself(передача владения) - Ключевое слово
Selfможно использовать как сокращение, подразумевающее тип
- Методы могут необязательно принимать
struct Point {x: u32, y: u32}
impl Point {
fn new(x: u32, y: u32) -> Self {
Point {x, y}
}
fn increment_x(&mut self) {
self.x += 1;
}
}
fn main() {
let mut p = Point::new(10, 20);
p.increment_x();
}
Упражнение: сложение и преобразование точки
🟡 Средний уровень — требует понимания разницы между перемещением и заимствованием по сигнатурам методов
- Реализуйте следующие ассоциированные методы для
Pointadd()принимает другуюPointи увеличивает значения x и y на месте (подсказка: используйте&mut self)transform()поглощает существующуюPoint(подсказка: используйтеself) и возвращает новуюPoint, возводя x и y в квадрат
Решение (нажмите, чтобы развернуть)
struct Point { x: u32, y: u32 }
impl Point {
fn new(x: u32, y: u32) -> Self {
Point { x, y }
}
fn add(&mut self, other: &Point) {
self.x += other.x;
self.y += other.y;
}
fn transform(self) -> Point {
Point { x: self.x * self.x, y: self.y * self.y }
}
}
fn main() {
let mut p1 = Point::new(2, 3);
let p2 = Point::new(10, 20);
p1.add(&p2);
println!("After add: x={}, y={}", p1.x, p1.y); // x=12, y=23
let p3 = p1.transform();
println!("After transform: x={}, y={}", p3.x, p3.y); // x=144, y=529
// p1 больше недоступен — transform() его поглотил
}
Управление памятью в Rust
Что вы узнаете: систему владения Rust — самую важную концепцию языка. После этой главы вы поймёте семантику перемещения, правила заимствования и трейт
Drop. Если вы усвоите эту главу, остальной Rust будет даваться естественно. Если трудно — перечитайте её: владение для большинства разработчиков C/C++ становится понятным при втором прочтении.
- Управление памятью в C/C++ — источник ошибок:
- В C память выделяется через
malloc()и освобождается черезfree(). Нет проверок на висячие указатели, использование после освобождения или двойное освобождение - В C++ помогают RAII (Resource Acquisition Is Initialization) и умные указатели, но
std::move(ptr)компилируется и после перемещения — использование после перемещения является неопределённым поведением
- В C память выделяется через
- Rust делает RAII надёжным даже для невнимательных:
- Перемещение разрушающее — компилятор не позволит трогать переменную, из которой переместили значение
- Правило пяти не нужно (нет конструктора копирования, конструктора перемещения, копирующего присваивания, перемещающего присваивания, деструктора)
- Rust даёт полный контроль над выделением памяти, но обеспечивает безопасность на этапе компиляции
- Это достигается комбинацией механизмов: владения, заимствования, изменяемости и времён жизни
- Выделения памяти во время выполнения в Rust могут происходить как на стеке, так и в куче
Для программистов C++ — соответствие умных указателей:
C++ Rust Улучшение безопасности std::unique_ptr<T>Box<T>Использование после перемещения невозможно std::shared_ptr<T>Rc<T>(один поток)Циклов ссылок по умолчанию нет std::shared_ptr<T>(потокобезопасный)Arc<T>Потокобезопасность задаётся явно std::weak_ptr<T>Weak<T>Валидность нужно проверять Сырой указатель *const T/*mut TТолько в блоках unsafeДля разработчиков на C:
Box<T>заменяет парыmalloc/free.Rc<T>заменяет ручной подсчёт ссылок. Сырые указатели существуют, но ограничены блокамиunsafe.
Владение, заимствование и времена жизни в Rust
- Напомним, что Rust допускает только одну изменяемую ссылку на переменную и несколько ссылок только для чтения
- Первоначальное объявление переменной устанавливает
владение - Последующие ссылки
заимствуюту первоначального владельца. Правило таково: область действия заимствования никогда не может превышать область владельца. Иначе говоря,время жизнизаимствования не может превышать время жизни владельца
- Первоначальное объявление переменной устанавливает
fn main() {
let a = 42; // Владелец
let b = &a; // Первое заимствование
{
let aa = 42;
let c = &a; // Второе заимствование; a всё ещё в области видимости
// Ок: c выходит из области видимости здесь
// aa выходит из области видимости здесь
}
// let d = &aa; // Не скомпилируется, если aa не перемещена за пределы области
// b неявно выходит из области видимости раньше a
// a выходит из области видимости последней
}
- Rust может передавать параметры в функции несколькими разными способами
- По значению (копия): обычно для типов, которые можно тривиально скопировать (например, u8, u32, i8, i32)
- По ссылке: это аналог передачи указателя на само значение. Также известно как заимствование; ссылка может быть неизменяемой (
&) или изменяемой (&mut) - Перемещением: это передаёт «владение» значением функции. Вызывающий код больше не может ссылаться на исходное значение
fn foo(x: &u32) {
println!("{x}");
}
fn bar(x: u32) {
println!("{x}");
}
fn main() {
let a = 42;
foo(&a); // По ссылке
bar(a); // По значению (копия)
}
- Rust запрещает висячие ссылки из методов
- Ссылки, возвращаемые методами, должны оставаться в области видимости
- Rust автоматически
уничтожаетссылку, когда она выходит из области видимости.
fn no_dangling() -> &u32 {
// Время жизни a начинается здесь
let a = 42;
// Не скомпилируется. Время жизни a заканчивается здесь
&a
}
fn ok_reference(a: &u32) -> &u32 {
// Ок, потому что время жизни a всегда превышает время жизни ok_reference()
a
}
fn main() {
let a = 42; // Время жизни a начинается здесь
let b = ok_reference(&a);
// Время жизни b заканчивается здесь
// Время жизни a заканчивается здесь
}
Семантика перемещения в Rust
- По умолчанию присваивание в Rust передаёт владение
fn main() {
let s = String::from("Rust"); // Выделяем строку в куче
let s1 = s; // Передаём владение s1. На этой точке s недействительна
println!("{s1}");
// Это не скомпилируется
//println!("{s}");
// s1 выходит из области видимости здесь, и память освобождается
// s выходит из области видимости здесь, но ничего не происходит, потому что она ничем не владеет
}
graph LR
subgraph "До: let s1 = s"
S["s (стек)<br/>ptr"] -->|"владеет"| H1["Куча: R u s t"]
end
subgraph "После: let s1 = s"
S_MOVED["s (стек)<br/>⚠️ ПЕРЕМЕЩЕНО"] -.->|"недействительна"| H2["Куча: R u s t"]
S1["s1 (стек)<br/>ptr"] -->|"теперь владеет"| H2
end
style S_MOVED fill:#ff6b6b,color:#000,stroke:#333
style S1 fill:#51cf66,color:#000,stroke:#333
style H2 fill:#91e5a3,color:#000,stroke:#333
После let s1 = s владение переходит к s1. Данные в куче остаются на месте — перемещается только указатель на стеке. s теперь недействительна.
Семантика перемещения и заимствование в Rust
fn foo(s : String) {
println!("{s}");
// Память в куче, на которую указывает s, освобождается здесь
}
fn bar(s : &String) {
println!("{s}");
// Ничего не происходит — s заимствована
}
fn main() {
let s = String::from("Rust string move example"); // Выделяем строку в куче
foo(s); // Передаём владение; s теперь недействительна
// println!("{s}"); // не скомпилируется
let t = String::from("Rust string borrow example");
bar(&t); // t продолжает владеть значением
println!("{t}");
}
Семантика перемещения и владение в Rust
- Владение можно передать перемещением
- Нельзя использовать ссылки, которые остались открытыми после завершения перемещения
- Рассмотрите заимствование, если перемещение нежелательно
struct Point {
x: u32,
y: u32,
}
fn consume_point(p: Point) {
println!("{} {}", p.x, p.y);
}
fn borrow_point(p: &Point) {
println!("{} {}", p.x, p.y);
}
fn main() {
let p = Point {x: 10, y: 20};
// Попробуйте поменять местами эти две строки
borrow_point(&p);
consume_point(p);
}
Clone в Rust
- Метод
clone()можно использовать для копирования исходной памяти. Исходная ссылка продолжает оставаться валидной (минус в том, что выделяется вдвое больше памяти)
fn main() {
let s = String::from("Rust"); // Выделяем строку в куче
let s1 = s.clone(); // Копируем строку; создаётся новое выделение в куче
println!("{s1}");
println!("{s}");
// s1 выходит из области видимости здесь, и память освобождается
// s выходит из области видимости здесь, и память освобождается
}
graph LR
subgraph "После: let s1 = s.clone()"
S["s (стек)<br/>ptr"] -->|"владеет"| H1["Куча: R u s t"]
S1["s1 (стек)<br/>ptr"] -->|"владеет (копия)"| H2["Куча: R u s t"]
end
style S fill:#51cf66,color:#000,stroke:#333
style S1 fill:#51cf66,color:#000,stroke:#333
style H1 fill:#91e5a3,color:#000,stroke:#333
style H2 fill:#91e5a3,color:#000,stroke:#333
clone() создаёт отдельное выделение в куче. Обе s и s1 валидны — каждая владеет своей копией.
Трейт Copy в Rust
- Rust реализует семантику копирования для встроенных типов через трейт
Copy- Примеры: u8, u32, i8, i32 и т. д. Семантика копирования использует «передачу по значению»
- Пользовательские типы данных могут при желании получить семантику
copy, использовав макросderiveдля автоматической реализации трейтаCopy - Компилятор выделит место под копию после нового присваивания
// Попробуйте закомментировать эту строку, чтобы увидеть изменение в let p1 = p; ниже
#[derive(Copy, Clone, Debug)] // Мы подробнее поговорим об этом позже
struct Point{x: u32, y:u32}
fn main() {
let p = Point {x: 42, y: 40};
let p1 = p; // Теперь здесь будет копирование вместо перемещения
println!("p: {p:?}");
println!("p1: {p:?}");
let p2 = p1.clone(); // Семантически то же, что и копирование
}
Трейт Drop в Rust
- Rust автоматически вызывает метод
drop()в конце области видимостиdrop— часть обобщённого трейтаDrop. Компилятор предоставляет пустую (NOP) реализацию для всех типов по умолчанию, но типы могут её переопределить. Например, типStringпереопределяет её, чтобы освободить память, выделенную в куче- Для разработчиков на C: это избавляет от необходимости вручную вызывать
free()— ресурсы автоматически освобождаются при выходе из области видимости (RAII)
- Ключевое свойство безопасности: нельзя вызвать
.drop()напрямую (компилятор это запрещает). Вместо этого используйтеdrop(obj), которая перемещает значение в функцию, запускает его деструктор и запрещает дальнейшее использование — это исключает ошибки двойного освобождения
Для программистов C++:
Dropнапрямую соответствует деструкторам C++ (~ClassName()):
Деструктор C++ Rust DropСинтаксис ~MyClass() { ... }impl Drop for MyType { fn drop(&mut self) { ... } }Когда вызывается В конце области видимости (RAII) В конце области видимости (так же) При перемещении Исходный объект остаётся в «допустимом, но не определённом состоянии» — деструктор всё равно вызывается для перемещённого объекта Исходного значения больше нет — деструктор для перемещённого значения не вызывается Ручной вызов obj.~MyClass()(опасно, редко используется)drop(obj)(безопасно — забирает владение, вызываетdropи запрещает дальнейшее использование)Порядок Обратный порядок объявления Обратный порядок объявления (так же) Правило пяти Нужно управлять конструктором копирования, конструктором перемещения, копирующим присваиванием, перемещающим присваиванием, деструктором Только Drop— компилятор обрабатывает семантику перемещения, аCloneподключается явноНужен виртуальный деструктор? Да, если удаляем через базовый указатель Нет — наследования нет, поэтому проблемы среза (slicing) не возникает
struct Point {x: u32, y:u32}
// Эквивалентно: ~Point() { printf("Goodbye point x:%u, y:%u\n", x, y); }
impl Drop for Point {
fn drop(&mut self) {
println!("Goodbye point x:{}, y:{}", self.x, self.y);
}
}
fn main() {
let p = Point{x: 42, y: 42};
{
let p1 = Point{x:43, y: 43};
println!("Exiting inner block");
// p1.drop() вызывается здесь — как деструктор C++ в конце области видимости
}
println!("Exiting main");
// p.drop() вызывается здесь
}
Упражнение: перемещение, копирование и Drop
🟡 Средний уровень — экспериментируйте свободно; компилятор подскажет
- Поэкспериментируйте с
PointсCopyв#[derive(Debug)]и без него, как показано ниже, и убедитесь, что понимаете разницу. Идея в том, чтобы твёрдо понять, как работают перемещение и копирование, поэтому обязательно задавайте вопросы - Реализуйте собственный
DropдляPoint, который вdropобнуляет x и y. Такой шаблон полезен, например, для освобождения блокировок и других ресурсов
struct Point{x: u32, y: u32}
fn main() {
// Создайте Point, присвойте её другой переменной, создайте новую область видимости,
// передайте point в функцию и т. д.
}
Решение (нажмите, чтобы развернуть)
#[derive(Debug)]
struct Point { x: u32, y: u32 }
impl Drop for Point {
fn drop(&mut self) {
println!("Dropping Point({}, {})", self.x, self.y);
self.x = 0;
self.y = 0;
// Примечание: обнуление в drop демонстрирует шаблон,
// но эти значения нельзя наблюдать после завершения drop
}
}
fn consume(p: Point) {
println!("Consuming: {:?}", p);
// p уничтожается здесь
}
fn main() {
let p1 = Point { x: 10, y: 20 };
let p2 = p1; // Перемещение — p1 больше не валидна
// println!("{:?}", p1); // Не скомпилируется: p1 была перемещена
{
let p3 = Point { x: 30, y: 40 };
println!("p3 in inner scope: {:?}", p3);
// p3 уничтожается здесь (конец области видимости)
}
consume(p2); // p2 перемещается в consume и уничтожается там
// println!("{:?}", p2); // Не скомпилируется: p2 была перемещена
// Теперь попробуйте: добавить #[derive(Copy, Clone)] к Point (и удалить реализацию Drop)
// и посмотрите, как p1 остаётся валидной после let p2 = p1;
}
// Вывод:
// p3 in inner scope: Point { x: 30, y: 40 }
// Dropping Point(30, 40)
// Consuming: Point { x: 10, y: 20 }
// Dropping Point(10, 20)
Времена жизни и заимствование в Rust
Что вы узнаете: как система времён жизни Rust гарантирует, что ссылки никогда не станут висячими — от неявных времён жизни через явные аннотации до трёх правил элизии, благодаря которым большинству кода аннотации не нужны. Понимание времён жизни здесь необходимо, прежде чем переходить к умным указателям в следующем разделе.
- Rust требует единственной изменяемой ссылки и любого количества неизменяемых ссылок
- Время жизни любой ссылки должно быть не меньше, чем время жизни исходного владельца. Это неявные времена жизни, которые выводит компилятор (см. https://doc.rust-lang.org/nomicon/lifetime-elision.html)
fn borrow_mut(x: &mut u32) {
*x = 43;
}
fn main() {
let mut x = 42;
let y = &mut x;
borrow_mut(y);
let _z = &x; // Допустимо, потому что компилятор знает, что y далее не используется
//println!("{y}"); // Не скомпилируется, если раскомментировать
borrow_mut(&mut x); // Допустимо, потому что _z не используется
let z = &x; // Ок — изменяемое заимствование x закончилось после возврата из borrow_mut()
println!("{z}");
}
Аннотации времён жизни в Rust
- Явные аннотации времён жизни нужны, когда имеют дело с несколькими временами жизни
- Времена жизни обозначаются символом
', и могут быть любым идентификатором ('a,'b,'staticи т. д.) - Компилятору нужна подсказка, когда он не может понять, сколько должны жить ссылки
- Времена жизни обозначаются символом
- Типичный сценарий: функция возвращает ссылку, но из какого входного параметра она взята?
#[derive(Debug)]
struct Point {x: u32, y: u32}
// Без аннотации времени жизни это не скомпилируется:
// fn left_or_right(pick_left: bool, left: &Point, right: &Point) -> &Point
// С аннотацией времени жизни — все ссылки разделяют одно время жизни 'a
fn left_or_right<'a>(pick_left: bool, left: &'a Point, right: &'a Point) -> &'a Point {
if pick_left { left } else { right }
}
// Сложнее: разные времена жизни для входных параметров
fn get_x_coordinate<'a, 'b>(p1: &'a Point, _p2: &'b Point) -> &'a u32 {
&p1.x // Время жизни возвращаемого значения привязано к p1, а не к p2
}
fn main() {
let p1 = Point {x: 20, y: 30};
let result;
{
let p2 = Point {x: 42, y: 50};
result = left_or_right(true, &p1, &p2);
// Это работает, потому что мы используем result до выхода p2 из области видимости
println!("Selected: {result:?}");
}
// Это НЕ сработает — result ссылается на p2, которого уже нет:
// println!("After scope: {result:?}");
}
Аннотации времён жизни в Rust
- Аннотации времён жизни также нужны для ссылок внутри структур данных
use std::collections::HashMap;
#[derive(Debug)]
struct Point {x: u32, y: u32}
struct Lookup<'a> {
map: HashMap<u32, &'a Point>,
}
fn main() {
let p = Point{x: 42, y: 42};
let p1 = Point{x: 50, y: 60};
let mut m = Lookup {map : HashMap::new()};
m.map.insert(0, &p);
m.map.insert(1, &p1);
{
let p3 = Point{x: 60, y:70};
//m.map.insert(3, &p3); // Не скомпилируется
// p3 уничтожается здесь, но m переживёт её
}
for (k, v) in m.map {
println!("{v:?}");
}
// m уничтожается здесь
// p1 и p уничтожаются здесь, в этом порядке
}
Упражнение: первое слово с временами жизни
🟢 Начальный уровень — практика элизии времён жизни в действии
Напишите функцию fn first_word(s: &str) -> &str, которая возвращает первое слово строки, разделённое пробелами. Подумайте, почему это компилируется без явных аннотаций времён жизни (подсказка: правила элизии №1 и №2).
Решение (нажмите, чтобы развернуть)
fn first_word(s: &str) -> &str {
// Компилятор применяет правила элизии:
// Правило 1: входной &str получает время жизни 'a → fn first_word(s: &'a str) -> &str
// Правило 2: одно входное время жизни → выход получает то же → fn first_word(s: &'a str) -> &'a str
match s.find(' ') {
Some(pos) => &s[..pos],
None => s,
}
}
fn main() {
let text = "hello world foo";
let word = first_word(text);
println!("First word: {word}"); // "hello"
let single = "onlyone";
println!("First word: {}", first_word(single)); // "onlyone"
}
Упражнение: хранение срезов с временами жизни
🟡 Средний уровень — ваше первое знакомство с аннотациями времён жизни
- Создайте структуру, которая хранит ссылки на срез
&str- Создайте длинный
&strи сохраните в структуре ссылки на его срезы - Напишите функцию, которая принимает структуру и возвращает содержащийся в ней срез
- Создайте длинный
// TODO: Создайте структуру для хранения ссылки на срез
struct SliceStore {
}
fn main() {
let s = "This is long string";
let s1 = &s[0..];
let s2 = &s[1..2];
// let slice = struct SliceStore {...};
// let slice2 = struct SliceStore {...};
}
Решение (нажмите, чтобы развернуть)
struct SliceStore<'a> {
slice: &'a str,
}
impl<'a> SliceStore<'a> {
fn new(slice: &'a str) -> Self {
SliceStore { slice }
}
fn get_slice(&self) -> &'a str {
self.slice
}
}
fn main() {
let s = "This is a long string";
let store1 = SliceStore::new(&s[0..4]); // "This"
let store2 = SliceStore::new(&s[5..7]); // "is"
println!("store1: {}", store1.get_slice());
println!("store2: {}", store2.get_slice());
}
// Вывод:
// store1: This
// store2: is
Углублённый разбор правил элизии времён жизни
Разработчики на C часто спрашивают: «Если времена жизни так важны, почему у большинства функций в Rust нет аннотаций 'a?» Ответ — элизия времён жизни: компилятор применяет три детерминированных правила, чтобы выводить времена жизни автоматически.
Три правила элизии
Компилятор Rust применяет эти правила по порядку к сигнатурам функций. Если после применения правил определены все времена жизни выходных значений, аннотации не нужны.
flowchart TD
A["Сигнатура функции<br/>со ссылками"] --> R1
R1["Правило 1: каждая входная<br/>ссылка получает собственное<br/>время жизни<br/><br/>fn f(&str, &str)<br/>→ fn f<'a,'b>(&'a str,<br/>&'b str)"]
R1 --> R2
R2["Правило 2: если есть ровно ОДНО<br/>входное время жизни, назначаем его<br/>ВСЕМ выходным<br/><br/>fn f(&str) → &str<br/>→ fn f<'a>(&'a str)<br/>→ &'a str"]
R2 --> R3
R3["Правило 3: если один из входов —<br/>&self или &mut self, назначаем его<br/>время жизни ВСЕМ выходным<br/><br/>fn f(&self, &str) → &str<br/>→ fn f<'a>(&'a self, &str)<br/>→ &'a str"]
R3 --> CHECK{{"Определены ли<br/>времена жизни всех<br/>выходных значений?"}}
CHECK -->|Да| OK["✅ Аннотации<br/>не нужны"]
CHECK -->|Нет| ERR["❌ Ошибка компиляции:<br/>нужно аннотировать<br/>вручную"]
style OK fill:#91e5a3,color:#000
style ERR fill:#ff6b6b,color:#000
Разбор правил на примерах
Правило 1 — каждая входная ссылка получает собственный параметр времени жизни:
#![allow(unused)]
fn main() {
// Что вы пишете:
fn first_word(s: &str) -> &str { ... }
// Что видит компилятор после правила 1:
fn first_word<'a>(s: &'a str) -> &str { ... }
// Есть только одно входное время жизни → применяется правило 2
}
Правило 2 — единственное входное время жизни распространяется на все выходные:
#![allow(unused)]
fn main() {
// После правила 2:
fn first_word<'a>(s: &'a str) -> &'a str { ... }
// ✅ Все выходные времена жизни определены — аннотация не нужна!
}
Правило 3 — время жизни &self распространяется на выходные значения:
#![allow(unused)]
fn main() {
// Что вы пишете:
impl SliceStore<'_> {
fn get_slice(&self) -> &str { self.slice }
}
// Что видит компилятор после правил 1 и 3:
impl SliceStore<'_> {
fn get_slice<'a>(&'a self) -> &'a str { self.slice }
}
// ✅ Аннотация не нужна — время жизни &self используется для результата
}
Когда элизия не срабатывает — нужно аннотировать:
#![allow(unused)]
fn main() {
// Два входных параметра-ссылки, нет &self → правила 2 и 3 не применяются
// fn longest(a: &str, b: &str) -> &str ← НЕ СКОМПИЛИРУЕТСЯ
// Исправление: сообщаем компилятору, из какого входа заимствует результат
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() >= b.len() { a } else { b }
}
}
Модель мышления программиста на C
В C каждый указатель независим — программист мысленно отслеживает, на какое выделение памяти ссылается каждый указатель, а компилятор полностью доверяет вам. В Rust времена жизни делают это отслеживание явным и проверяемым компилятором:
| C | Rust | Что происходит |
|---|---|---|
char* get_name(struct User* u) | fn get_name(&self) -> &str | Правило 3 выводит: результат заимствован у self |
char* concat(char* a, char* b) | fn concat<'a>(a: &'a str, b: &'a str) -> &'a str | Нужна аннотация — два входа |
void process(char* in, char* out) | fn process(input: &str, output: &mut String) | Выходной ссылки нет — время жизни не нужно |
char* buf; /* кто владеет этим? */ | Ошибка компиляции, если время жизни неверно | Компилятор ловит висячие указатели |
Время жизни 'static
'static означает, что ссылка валидна на протяжении всего времени работы программы. Это аналог глобальной переменной или строкового литерала в C:
#![allow(unused)]
fn main() {
// Строковые литералы всегда 'static — они находятся в read-only секции бинарника
let s: &'static str = "hello"; // То же, что static const char* s = "hello"; в C
// Константы тоже 'static
static GREETING: &str = "hello";
// Часто встречается в ограничениях трейтов при запуске потоков:
fn spawn<F: FnOnce() + Send + 'static>(f: F) { /* ... */ }
// 'static здесь означает: «замыкание не должно заимствовать никакие локальные переменные»
// (либо перемещать их внутрь, либо использовать только данные 'static)
}
Упражнение: предскажите элизию
🟡 Средний уровень
Для каждой сигнатуры функции ниже предскажите, может ли компилятор вывести времена жизни. Если нет, добавьте необходимые аннотации:
#![allow(unused)]
fn main() {
// 1. Может ли компилятор вывести?
fn trim_prefix(s: &str) -> &str { &s[1..] }
// 2. Может ли компилятор вывести?
fn pick(flag: bool, a: &str, b: &str) -> &str {
if flag { a } else { b }
}
// 3. Может ли компилятор вывести?
struct Parser { data: String }
impl Parser {
fn next_token(&self) -> &str { &self.data[..5] }
}
// 4. Может ли компилятор вывести?
fn split_at(s: &str, pos: usize) -> (&str, &str) {
(&s[..pos], &s[pos..])
}
}
Решение (нажмите, чтобы развернуть)
// 1. ДА — правило 1 даёт 'a для s, правило 2 распространяет его на результат
fn trim_prefix(s: &str) -> &str { &s[1..] }
// 2. НЕТ — два входных параметра-ссылки, нет &self. Нужна аннотация:
fn pick<'a>(flag: bool, a: &'a str, b: &'a str) -> &'a str {
if flag { a } else { b }
}
// 3. ДА — правило 1 даёт 'a для &self, правило 3 распространяет его на результат
impl Parser {
fn next_token(&self) -> &str { &self.data[..5] }
}
// 4. ДА — правило 1 даёт 'a для s (единственная входная ссылка),
// правило 2 распространяет его на ОБА выхода. Оба среза заимствованы у s.
fn split_at(s: &str, pos: usize) -> (&str, &str) {
(&s[..pos], &s[pos..])
}
Box<T> в Rust
Что вы узнаете: умные указатели Rust —
Box<T>для выделения памяти в куче,Rc<T>для совместного владения иCell<T>/RefCell<T>для внутренней изменяемости. Они опираются на понятия владения и времён жизни из предыдущих разделов. Вы также кратко познакомитесь сWeak<T>для разрыва циклов ссылок.
Зачем Box<T>? В C для выделения памяти в куче используют malloc/free. В C++ std::unique_ptr<T> оборачивает new/delete. Box<T> в Rust — это аналог: указатель в куче с единственным владельцем, который автоматически освобождается при выходе из области видимости. В отличие от malloc, нет парного free, который можно забыть. В отличие от unique_ptr, нет использования после перемещения — компилятор полностью это предотвращает.
Когда использовать Box, а когда выделение на стеке:
-
Содержащийся тип большой, и вы не хотите копировать его на стеке
-
Нужен рекурсивный тип (например, узел связного списка, который содержит сам себя)
-
Нужны трейт-объекты (
Box<dyn Trait>) -
Box<T>можно использовать, чтобы создать указатель на тип, размещённый в куче. Размер указателя всегда фиксирован, независимо от типа<T>
fn main() {
// Создаём указатель на целое число (со значением 42), размещённое в куче
let f = Box::new(42);
println!("{} {}", *f, f);
// Клонирование box создаёт новое выделение в куче
let mut g = f.clone();
*g = 43;
println!("{f} {g}");
// g и f выходят из области видимости здесь и автоматически освобождаются
}
graph LR
subgraph "Стек"
F["f: Box<i32>"]
G["g: Box<i32>"]
end
subgraph "Куча"
HF["42"]
HG["43"]
end
F -->|"владеет"| HF
G -->|"владеет (клон)"| HG
style F fill:#51cf66,color:#000,stroke:#333
style G fill:#51cf66,color:#000,stroke:#333
style HF fill:#91e5a3,color:#000,stroke:#333
style HG fill:#91e5a3,color:#000,stroke:#333
Визуализация владения и заимствования
C/C++ и Rust: управление указателями и владением
// C — ручное управление памятью, потенциальные проблемы
void c_pointer_problems() {
int* ptr1 = malloc(sizeof(int));
*ptr1 = 42;
int* ptr2 = ptr1; // Оба указывают на одну и ту же память
int* ptr3 = ptr1; // Три указателя на одну и ту же память
free(ptr1); // Освобождает память
*ptr2 = 43; // Использование после освобождения — неопределённое поведение!
*ptr3 = 44; // Использование после освобождения — неопределённое поведение!
}
Для программистов C++: умные указатели помогают, но не предотвращают все проблемы:
// C++ — умные указатели помогают, но не предотвращают все проблемы void cpp_pointer_issues() { auto ptr1 = std::make_unique<int>(42); // auto ptr2 = ptr1; // Ошибка компиляции: unique_ptr нельзя копировать auto ptr2 = std::move(ptr1); // Ок: владение передано // Но C++ всё равно допускает использование после перемещения: // std::cout << *ptr1; // Компилируется! Но это неопределённое поведение! // Псевдонимы shared_ptr: auto shared1 = std::make_shared<int>(42); auto shared2 = shared1; // Оба владеют данными // Кто «на самом деле» владеет? Никто. Накладные расходы на счётчик ссылок везде. }
#![allow(unused)]
fn main() {
// Rust — система владения предотвращает эти проблемы
fn rust_ownership_safety() {
let data = Box::new(42); // data владеет выделением в куче
let moved_data = data; // Владение передано moved_data
// data больше недоступна — ошибка компиляции при использовании
let borrowed = &moved_data; // Неизменяемое заимствование
println!("{}", borrowed); // Безопасно использовать
// moved_data автоматически освобождается при выходе из области видимости
}
}
graph TD
subgraph "Проблемы управления памятью в C/C++"
CP1["int* ptr1"] --> CM["Память в куче<br/>значение: 42"]
CP2["int* ptr2"] --> CM
CP3["int* ptr3"] --> CM
CF["free(ptr1)"] --> CM_F["[ОШИБКА] Освобождённая память"]
CP2 -.->|"Использование после освобождения<br/>Неопределённое поведение"| CM_F
CP3 -.->|"Использование после освобождения<br/>Неопределённое поведение"| CM_F
end
subgraph "Система владения Rust"
RO1["data: Box<i32>"] --> RM["Память в куче<br/>значение: 42"]
RO1 -.->|"Передача владения"| RO2["moved_data: Box<i32>"]
RO2 --> RM
RO1_X["data: [ВНИМАНИЕ] ПЕРЕМЕЩЕНО<br/>Доступа нет"]
RB["&moved_data<br/>Неизменяемое заимствование"] -.->|"Безопасная ссылка"| RM
RD["Drop автоматически<br/>при выходе из области видимости"] --> RM
end
style CM_F fill:#ff6b6b,color:#000
style CP2 fill:#ff6b6b,color:#000
style CP3 fill:#ff6b6b,color:#000
style RO1_X fill:#ffa07a,color:#000
style RO2 fill:#51cf66,color:#000
style RB fill:#91e5a3,color:#000
style RD fill:#91e5a3,color:#000
Визуализация правил заимствования
#![allow(unused)]
fn main() {
fn borrowing_rules_example() {
let mut data = vec![1, 2, 3, 4, 5];
// Несколько неизменяемых заимствований — Ок
let ref1 = &data;
let ref2 = &data;
println!("{:?} {:?}", ref1, ref2); // Оба можно использовать
// Изменяемое заимствование — эксклюзивный доступ
let ref_mut = &mut data;
ref_mut.push(6);
// ref1 и ref2 нельзя использовать, пока активна ref_mut
// Когда ref_mut закончена, неизменяемые заимствования снова работают
let ref3 = &data;
println!("{:?}", ref3);
}
}
graph TD
subgraph "Правила заимствования в Rust"
D["mut data: Vec<i32>"]
subgraph "Фаза 1: несколько неизменяемых заимствований [OK]"
IR1["&data (ref1)"]
IR2["&data (ref2)"]
D --> IR1
D --> IR2
IR1 -.->|"Доступ только для чтения"| MEM1["Память: [1,2,3,4,5]"]
IR2 -.->|"Доступ только для чтения"| MEM1
end
subgraph "Фаза 2: эксклюзивное изменяемое заимствование [OK]"
MR["&mut data (ref_mut)"]
D --> MR
MR -.->|"Эксклюзивное чтение/запись"| MEM2["Память: [1,2,3,4,5,6]"]
BLOCK["[ОШИБКА] Остальные заимствования заблокированы"]
end
subgraph "Фаза 3: снова неизменяемые заимствования [OK]"
IR3["&data (ref3)"]
D --> IR3
IR3 -.->|"Доступ только для чтения"| MEM3["Память: [1,2,3,4,5,6]"]
end
end
subgraph "Что разрешают C/C++ (опасно)"
CP["int* ptr"]
CP2["int* ptr2"]
CP3["int* ptr3"]
CP --> CMEM["Та же память"]
CP2 --> CMEM
CP3 --> CMEM
RACE["[ОШИБКА] Возможны гонки данных<br/>[ОШИБКА] Возможно использование после освобождения"]
end
style MEM1 fill:#91e5a3,color:#000
style MEM2 fill:#91e5a3,color:#000
style MEM3 fill:#91e5a3,color:#000
style BLOCK fill:#ffa07a,color:#000
style RACE fill:#ff6b6b,color:#000
style CMEM fill:#ff6b6b,color:#000
Внутренняя изменяемость: Cell<T> и RefCell<T>
Напомним, что по умолчанию переменные в Rust неизменяемые. Иногда хочется, чтобы большая часть типа была доступна только для чтения, но при этом разрешалась запись в одно поле.
#![allow(unused)]
fn main() {
struct Employee {
employee_id : u64, // Это поле должно быть неизменяемым
on_vacation: bool, // А что, если мы хотим разрешить запись в это поле, но сделать employee_id неизменяемым?
}
}
- Напомним, что Rust допускает одну изменяемую ссылку на переменную и любое количество неизменяемых ссылок — это проверяется на этапе компиляции
- Что, если мы хотим передать неизменяемый вектор сотрудников, но при этом разрешить обновлять поле
on_vacation, гарантируя, чтоemployee_idнельзя будет изменить?
Cell<T> — внутренняя изменяемость для типов Copy
Cell<T>обеспечивает внутреннюю изменяемость, то есть доступ на запись к отдельным элементам через ссылки, которые в остальном доступны только для чтения- Работает копированием значений внутрь и наружу (для
.get()требуетсяT: Copy)
RefCell<T> — внутренняя изменяемость с проверкой заимствований во время выполнения
RefCell<T>— вариант, который работает со ссылками- Применяет проверку заимствований Rust во время выполнения, а не на этапе компиляции
- Допускает одно изменяемое заимствование, но вызывает panic, если есть любые другие активные ссылки
- Используйте
.borrow()для неизменяемого доступа и.borrow_mut()для изменяемого
Когда выбрать Cell, а когда RefCell
| Критерий | Cell<T> | RefCell<T> |
|---|---|---|
| Работает с | Типами Copy (целые, bool, числа с плавающей точкой) | Любыми типами (String, Vec, структуры) |
| Схема доступа | Копирует значения внутрь/наружу (.get(), .set()) | Заимствует на месте (.borrow(), .borrow_mut()) |
| Режим сбоя | Не может упасть — нет проверок во время выполнения | Panic, если берёте изменяемое заимствование, пока активно другое |
| Накладные расходы | Нулевые — просто копирование байтов | Небольшие — отслеживание состояния заимствования во время выполнения |
| Использовать, когда | Нужен изменяемый флаг, счётчик или небольшое значение внутри неизменяемой структуры | Нужно изменить String, Vec или сложный тип внутри неизменяемой структуры |
Совместное владение: Rc<T>
Rc<T> позволяет совместное владение неизменяемыми данными с подсчётом ссылок. Что, если мы хотим хранить одного и того же Employee в нескольких местах без копирования?
#[derive(Debug)]
struct Employee {
employee_id: u64,
}
fn main() {
let mut us_employees = vec![];
let mut all_global_employees = Vec::<Employee>::new();
let employee = Employee { employee_id: 42 };
us_employees.push(employee);
// Не скомпилируется — employee уже перемещён
//all_global_employees.push(employee);
}
Rc<T> решает эту проблему, разрешая совместный неизменяемый доступ:
- Содержащийся тип автоматически разыменовывается
- Тип уничтожается, когда счётчик ссылок становится 0
use std::rc::Rc;
#[derive(Debug)]
struct Employee {employee_id: u64}
fn main() {
let mut us_employees = vec![];
let mut all_global_employees = vec![];
let employee = Employee { employee_id: 42 };
let employee_rc = Rc::new(employee);
us_employees.push(employee_rc.clone());
all_global_employees.push(employee_rc.clone());
let employee_one = all_global_employees.get(0); // Совместная неизменяемая ссылка
for e in us_employees {
println!("{}", e.employee_id); // Совместная неизменяемая ссылка
}
println!("{employee_one:?}");
}
Для программистов C++: соответствие умных указателей
Умный указатель C++ Эквивалент в Rust Ключевое отличие std::unique_ptr<T>Box<T>Версия Rust используется по умолчанию — перемещение задаётся на уровне языка, а не включается явно std::shared_ptr<T>Rc<T>(один поток) /Arc<T>(несколько потоков)У Rcнет накладных расходов на атомарные операции;Arcиспользуйте, только когда делитесь данными между потокамиstd::weak_ptr<T>Weak<T>(изRc::downgrade()илиArc::downgrade())Та же цель: разрыв циклов ссылок Ключевое различие: в C++ вы выбираете использовать умные указатели. В Rust владеющие значения (
T) и заимствование (&T) покрывают большинство случаев — прибегайте кBox/Rc/Arc, только когда нужно выделение в куче или совместное владение.
Разрыв циклов ссылок с помощью Weak<T>
Rc<T> использует подсчёт ссылок — если два значения Rc указывают друг на друга, ни одно из них никогда не будет уничтожено (цикл). Weak<T> решает эту проблему:
use std::rc::{Rc, Weak};
struct Node {
value: i32,
parent: Option<Weak<Node>>, // Слабая ссылка — не мешает уничтожению
}
fn main() {
let parent = Rc::new(Node { value: 1, parent: None });
let child = Rc::new(Node {
value: 2,
parent: Some(Rc::downgrade(&parent)), // Слабая ссылка на родителя
});
// Чтобы использовать Weak, попробуйте повысить его — возвращается Option<Rc<T>>
if let Some(parent_rc) = child.parent.as_ref().unwrap().upgrade() {
println!("Parent value: {}", parent_rc.value);
}
println!("Parent strong count: {}", Rc::strong_count(&parent)); // 1, а не 2
}
Weak<T>подробнее рассматривается в главе Избегание избыточного clone(). Пока главное: используйтеWeakдля «обратных ссылок» в древовидных и графовых структурах, чтобы избежать утечек памяти.
Комбинирование Rc с внутренней изменяемостью
Настоящая мощь проявляется, когда вы комбинируете Rc<T> (совместное владение) с Cell<T> или RefCell<T> (внутренняя изменяемость). Это позволяет нескольким владельцам читать и изменять общие данные:
| Шаблон | Сценарий использования |
|---|---|
Rc<RefCell<T>> | Общие изменяемые данные (один поток) |
Arc<Mutex<T>> | Общие изменяемые данные (несколько потоков — см. ch13) |
Rc<Cell<T>> | Общие изменяемые типы Copy (простые флаги, счётчики) |
Упражнение: совместное владение и внутренняя изменяемость
🟡 Средний уровень
- Часть 1 (Rc): создайте структуру
Employeeс полямиemployee_id: u64иname: String. Поместите её вRc<Employee>и клонируйте в два отдельныхVec(us_employeesиglobal_employees). Выведите данные из обоих векторов, чтобы показать, что они разделяют одни и те же данные. - Часть 2 (Cell): добавьте поле
on_vacation: Cell<bool>вEmployee. Передайте неизменяемую ссылку&Employeeв функцию и переключитеon_vacationизнутри этой функции — без того, чтобы делать ссылку изменяемой. - Часть 3 (RefCell): замените
name: Stringнаname: RefCell<String>и напишите функцию, которая добавляет суффикс к имени сотрудника через&Employee(неизменяемую ссылку).
Стартовый код:
use std::cell::{Cell, RefCell};
use std::rc::Rc;
#[derive(Debug)]
struct Employee {
employee_id: u64,
name: RefCell<String>,
on_vacation: Cell<bool>,
}
fn toggle_vacation(emp: &Employee) {
// TODO: Переключите on_vacation с помощью Cell::set()
}
fn append_title(emp: &Employee, title: &str) {
// TODO: Заимствуйте name изменяемо через RefCell и добавьте title с помощью push_str
}
fn main() {
// TODO: Создайте сотрудника, оберните в Rc, клонируйте в два Vec,
// вызовите toggle_vacation и append_title, выведите результаты
}
Решение (нажмите, чтобы развернуть)
use std::cell::{Cell, RefCell};
use std::rc::Rc;
#[derive(Debug)]
struct Employee {
employee_id: u64,
name: RefCell<String>,
on_vacation: Cell<bool>,
}
fn toggle_vacation(emp: &Employee) {
emp.on_vacation.set(!emp.on_vacation.get());
}
fn append_title(emp: &Employee, title: &str) {
emp.name.borrow_mut().push_str(title);
}
fn main() {
let emp = Rc::new(Employee {
employee_id: 42,
name: RefCell::new("Alice".to_string()),
on_vacation: Cell::new(false),
});
let mut us_employees = vec![];
let mut global_employees = vec![];
us_employees.push(Rc::clone(&emp));
global_employees.push(Rc::clone(&emp));
// Переключаем отпуск через неизменяемую ссылку
toggle_vacation(&emp);
println!("On vacation: {}", emp.on_vacation.get()); // true
// Добавляем звание через неизменяемую ссылку
append_title(&emp, ", Sr. Engineer");
println!("Name: {}", emp.name.borrow()); // "Alice, Sr. Engineer"
// Оба Vec видят одни и те же данные (Rc разделяет владение)
println!("US: {:?}", us_employees[0].name.borrow());
println!("Global: {:?}", global_employees[0].name.borrow());
println!("Rc strong count: {}", Rc::strong_count(&emp));
}
// Вывод:
// On vacation: true
// Name: Alice, Sr. Engineer
// US: "Alice, Sr. Engineer"
// Global: "Alice, Sr. Engineer"
// Rc strong count: 3
Крейты и модули в Rust
Что вы узнаете: как Rust организует код в модули и крейты — видимость по умолчанию (приватная), модификаторы
pub, рабочие пространства и экосистемуcrates.io. Заменяет заголовочные файлы C/C++,#includeи управление зависимостями в CMake.
- Модули — базовая организационная единица кода внутри крейтов
- Каждый исходный файл (.rs) — это отдельный модуль, и он может создавать вложенные модули с помощью ключевого слова
mod. - Все типы внутри (под)модуля по умолчанию приватные и не видны извне в пределах того же крейта, если явно не помечены как
pub(публичные). Область действияpubможно дополнительно ограничить черезpub(crate)и т. д. - Даже публичный тип автоматически не становится видимым в другом модуле, если он не импортирован с помощью ключевого слова
use. Дочерние подмодули могут обращаться к типам родительской области черезuse super:: - Исходные файлы (.rs) не включаются в крейт автоматически, если явно не перечислены в
main.rs(исполняемый файл) илиlib.rs
- Каждый исходный файл (.rs) — это отдельный модуль, и он может создавать вложенные модули с помощью ключевого слова
Упражнение: модули и функции
- Посмотрим, как изменить наш hello world, чтобы он вызывал другую функцию
- Как уже говорилось, функции определяются ключевым словом
fn. Ключевой элемент->объявляет, что функция возвращает значение (по умолчанию — void) типаu32(беззнаковое 32-битное целое) - Функции ограничены областью модуля, то есть две функции с одинаковым именем в разных модулях не конфликтуют
- Ограничение модулем распространяется на все типы (например,
struct fooвmod a { struct foo; }— это отдельный тип (a::foo), отличный отmod b { struct foo; }(b::foo))
- Ограничение модулем распространяется на все типы (например,
- Как уже говорилось, функции определяются ключевым словом
Стартовый код — дополните функции:
mod math {
// TODO: реализуйте pub fn add(a: u32, b: u32) -> u32
}
fn greet(name: &str) -> String {
// TODO: верните "Hello, <name>! The secret number is <math::add(21,21)>"
todo!()
}
fn main() {
println!("{}", greet("Rustacean"));
}
Решение (нажмите, чтобы развернуть)
mod math {
pub fn add(a: u32, b: u32) -> u32 {
a + b
}
}
fn greet(name: &str) -> String {
format!("Hello, {}! The secret number is {}", name, math::add(21, 21))
}
fn main() {
println!("{}", greet("Rustacean"));
}
// Вывод: Hello, Rustacean! The secret number is 42
Рабочие пространства и крейты (пакеты)
- Любой серьёзный проект на Rust должен использовать рабочие пространства (workspaces) для организации составных крейтов
- Рабочее пространство — это просто набор локальных крейтов, которые используются для сборки целевых бинарных файлов.
Cargo.tomlв корне рабочего пространства должен содержать ссылки на составные пакеты (крейты)
- Рабочее пространство — это просто набор локальных крейтов, которые используются для сборки целевых бинарных файлов.
[workspace]
resolver = "2"
members = ["package1", "package2"]
workspace_root/
|-- Cargo.toml # Конфигурация рабочего пространства
|-- package1/
| |-- Cargo.toml # Конфигурация пакета 1
| `-- src/
| `-- lib.rs # Исходный код пакета 1
|-- package2/
| |-- Cargo.toml # Конфигурация пакета 2
| `-- src/
| `-- main.rs # Исходный код пакета 2
Упражнение: использование рабочих пространств и зависимостей пакетов
- Создадим простой пакет и используем его из нашей программы
hello world - Создайте каталог рабочего пространства
mkdir workspace
cd workspace
- Создайте файл Cargo.toml и добавьте в него следующее. Это создаёт пустое рабочее пространство
[workspace]
resolver = "2"
members = []
- Добавьте пакеты (
cargo new --libсоздаёт библиотеку вместо исполняемого файла)
cargo new hello
cargo new --lib hellolib
Упражнение: использование рабочих пространств и зависимостей пакетов
- Посмотрите сгенерированные Cargo.toml в
helloиhellolib. Обратите внимание, что оба добавлены в Cargo.toml верхнего уровня - Наличие
lib.rsвhellolibозначает, что это библиотечный пакет (см. https://doc.rust-lang.org/cargo/reference/cargo-targets.html, там описаны варианты настройки) - Добавляем зависимость от
hellolibв Cargo.toml дляhello
[dependencies]
hellolib = {path = "../hellolib"}
- Используем
add()изhellolib
fn main() {
println!("Hello, world! {}", hellolib::add(21, 21));
}
Решение (нажмите, чтобы развернуть)
Полная настройка рабочего пространства:
# Команды терминала
mkdir workspace && cd workspace
# Создаём Cargo.toml рабочего пространства
cat > Cargo.toml << 'EOF'
[workspace]
resolver = "2"
members = ["hello", "hellolib"]
EOF
cargo new hello
cargo new --lib hellolib
# hello/Cargo.toml — добавляем зависимость
[dependencies]
hellolib = {path = "../hellolib"}
#![allow(unused)]
fn main() {
// hellolib/src/lib.rs — add() уже есть после cargo new --lib
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
}
// hello/src/main.rs
fn main() {
println!("Hello, world! {}", hellolib::add(21, 21));
}
// Вывод: Hello, world! 42
Использование крейтов сообщества из crates.io
- В Rust богатая экосистема крейтов сообщества (см. https://crates.io/)
- Философия Rust — держать стандартную библиотеку компактной и выносить функциональность в крейты сообщества
- Жёсткого правила насчёт использования крейтов сообщества нет, но ориентир такой: крейт должен быть достаточно зрелым (это видно по номеру версии) и активно поддерживаться. Если сомневаетесь в крейте, уточните во внутренних источниках
- Каждый крейт, опубликованный на
crates.io, имеет мажорную и минорную версии- Ожидается, что крейты соблюдают рекомендации ```SemVer`` по мажорной и минорной версиям: https://doc.rust-lang.org/cargo/reference/semver.html
- Вкратце: внутри одной минорной версии не должно быть несовместимых изменений. Например, v0.11 должна быть совместима с v0.15 (а вот v0.20 может содержать несовместимые изменения)
Зависимости крейтов и SemVer
- Крейты могут объявлять зависимости от конкретной версии крейта, конкретной минорной или мажорной версии либо быть безразличными к версии. Следующие примеры показывают записи в
Cargo.tomlдля объявления зависимости от крейтаrand - Не ниже
0.10.0, но подходит всё, что< 0.11.0
[dependencies]
rand = { version = "0.10.0"}
- Только
0.10.0, и ничего другого
[dependencies]
rand = { version = "=0.10.0"}
- Версия не важна;
cargoвыберет последнюю
[dependencies]
rand = { version = "*"}
- Справка: https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html
Упражнение: использование крейта rand
- Измените пример
helloworld, чтобы он выводил случайное число - Используйте
cargo add randдля добавления зависимости - Используйте
https://docs.rs/rand/latest/rand/как справочник по API
Стартовый код — добавьте это в main.rs после выполнения cargo add rand:
use rand::RngExt;
fn main() {
let mut rng = rand::rng();
// TODO: Сгенерируйте и выведите случайное u32 в диапазоне 1..=100
// TODO: Сгенерируйте и выведите случайный bool
// TODO: Сгенерируйте и выведите случайный f64
}
Решение (нажмите, чтобы развернуть)
use rand::RngExt;
fn main() {
let mut rng = rand::rng();
let n: u32 = rng.random_range(1..=100);
println!("Random number (1-100): {n}");
// Генерируем случайный булев
let b: bool = rng.random();
println!("Random bool: {b}");
// Генерируем случайное число с плавающей точкой от 0.0 до 1.0
let f: f64 = rng.random();
println!("Random float: {f:.4}");
}
Cargo.toml и Cargo.lock
- Как уже говорилось, Cargo.lock автоматически генерируется из Cargo.toml
- Основная идея Cargo.lock — обеспечить воспроизводимые сборки. Например, если в
Cargo.tomlуказана версия0.10.0, cargo вправе выбрать любую версию< 0.11.0 - Cargo.lock содержит конкретную версию крейта rand, которая использовалась при сборке.
- Рекомендуется включать
Cargo.lockв git-репозиторий, чтобы сборки были воспроизводимыми
- Основная идея Cargo.lock — обеспечить воспроизводимые сборки. Например, если в
Функция cargo test
- Модульные тесты Rust по соглашению находятся в том же исходном файле и обычно группируются в отдельный модуль
- Тестовый код никогда не попадает в итоговый бинарный файл. Это возможно благодаря функции
cfg(конфигурация). Конфигурации полезны, например, для платформо-специфичного кода (LinuxиWindows) - Тесты запускаются командой
cargo test. Справка: https://doc.rust-lang.org/reference/conditional-compilation.html
- Тестовый код никогда не попадает в итоговый бинарный файл. Это возможно благодаря функции
#![allow(unused)]
fn main() {
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
// Будет включён только во время тестирования
#[cfg(test)]
mod tests {
use super::*; // Делает видимыми все типы родительской области
#[test]
fn it_works() {
let result = add(2, 2); // Или то же самое: super::add(2, 2);
assert_eq!(result, 4);
}
}
}
Другие возможности Cargo
- У
cargoесть и другие полезные возможности, в том числе:cargo clippy— отличный способ линтинга кода на Rust. Как правило, предупреждения стоит исправлять (подавлять их имеет смысл лишь в действительно обоснованных случаях)cargo formatзапускает инструментrustfmtдля форматирования исходного кода. Использование этого инструмента обеспечивает единообразное форматирование кода в репозитории и кладёт конец спорам о стилеcargo docгенерирует документацию из комментариев в стиле///. Документация для всех крейтов наcrates.ioсоздана именно этим способом
Профили сборки: управление оптимизацией
В C вы передаёте -O0, -O2, -Os, -flto в gcc/clang. В Rust профили сборки настраиваются в Cargo.toml:
# Cargo.toml — настройка профилей сборки
[profile.dev]
opt-level = 0 # Без оптимизаций (быстрая компиляция, как -O0)
debug = true # Полные отладочные символы (как -g)
[profile.release]
opt-level = 3 # Максимальная оптимизация (как -O3)
lto = "fat" # Оптимизация на этапе компоновки (как -flto)
strip = true # Удалить символы (как команда strip)
codegen-units = 1 # Один блок генерации кода — компиляция медленнее, оптимизация лучше
panic = "abort" # Без таблиц раскрутки стека (меньше бинарный файл)
| Флаг C/GCC | Ключ в Cargo.toml | Значения |
|---|---|---|
-O0 / -O2 / -O3 | opt-level | 0, 1, 2, 3, "s", "z" |
-flto | lto | false, "thin", "fat" |
-g / без -g | debug | true, false, "line-tables-only" |
команда strip | strip | "none", "debuginfo", "symbols", true/false |
| — | codegen-units | 1 = лучшая оптимизация, самая медленная компиляция |
cargo build # Использует [profile.dev]
cargo build --release # Использует [profile.release]
Скрипты сборки (build.rs): подключение C-библиотек
В C для подключения библиотек и запуска генерации кода используют Makefile или CMake.
В Rust для этого в корне крейта есть файл build.rs:
// build.rs — запускается перед компиляцией крейта
fn main() {
// Подключаем системную C-библиотеку (как -lbmc_ipmi в gcc)
println!("cargo::rustc-link-lib=bmc_ipmi");
// Где искать библиотеку (как -L/usr/lib/bmc)
println!("cargo::rustc-link-search=/usr/lib/bmc");
// Пересобрать, если изменится заголовочный файл C
println!("cargo::rerun-if-changed=wrapper.h");
}
Можно даже компилировать исходники на C прямо из крейта Rust:
# Cargo.toml
[build-dependencies]
cc = "1" # Интеграция с C-компилятором
// build.rs
fn main() {
cc::Build::new()
.file("src/c_helpers/ipmi_raw.c")
.include("/usr/include/bmc")
.compile("ipmi_raw"); // Создаёт libipmi_raw.a, который подключается автоматически
println!("cargo::rerun-if-changed=src/c_helpers/ipmi_raw.c");
}
| C / Make / CMake | Rust build.rs |
|---|---|
-lfoo | println!("cargo::rustc-link-lib=foo") |
-L/path | println!("cargo::rustc-link-search=/path") |
| Компиляция исходников C | cc::Build::new().file("foo.c").compile("foo") |
| Генерация кода | Записать файлы в $OUT_DIR, затем include!() |
Кросс-компиляция
В C для кросс-компиляции нужно установить отдельный тулчейн (arm-linux-gnueabihf-gcc)
и настроить Make/CMake. В Rust:
# Устанавливаем цель для кросс-компиляции
rustup target add aarch64-unknown-linux-gnu
# Кросс-компиляция
cargo build --target aarch64-unknown-linux-gnu --release
Компоновщик указывается в .cargo/config.toml:
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
| Кросс-компиляция в C | Аналог в Rust |
|---|---|
apt install gcc-aarch64-linux-gnu | rustup target add aarch64-unknown-linux-gnu + установка компоновщика |
CC=aarch64-linux-gnu-gcc make | .cargo/config.toml, [target.X] linker = "..." |
#ifdef __aarch64__ | #[cfg(target_arch = "aarch64")] |
| Отдельные цели Makefile | cargo build --target ... |
Флаги функций: условная компиляция
В C для условной компиляции используют #ifdef и -DFOO. В Rust — флаги функций,
объявленные в Cargo.toml:
# Cargo.toml
[features]
default = ["json"] # Включено по умолчанию
json = ["dep:serde_json"] # Необязательная зависимость
verbose = [] # Флаг без зависимости
gpu = ["dep:cuda-sys"] # Необязательная поддержка GPU
#![allow(unused)]
fn main() {
// Код, зависящий от флагов:
#[cfg(feature = "json")]
pub fn parse_config(data: &str) -> Result<Config, Error> {
serde_json::from_str(data).map_err(Error::from)
}
#[cfg(feature = "verbose")]
macro_rules! verbose {
($($arg:tt)*) => { eprintln!("[VERBOSE] {}", format!($($arg)*)); }
}
#[cfg(not(feature = "verbose"))]
macro_rules! verbose {
($($arg:tt)*) => {}; // Компилируется в ничего
}
}
| Препроцессор C | Флаги функций Rust |
|---|---|
gcc -DDEBUG | cargo build --features verbose |
#ifdef DEBUG | #[cfg(feature = "verbose")] |
#define MAX 100 | const MAX: u32 = 100; |
#ifdef __linux__ | #[cfg(target_os = "linux")] |
Интеграционные тесты и модульные тесты
Модульные тесты располагаются рядом с кодом и помечаются #[cfg(test)]. Интеграционные тесты находятся в каталоге
tests/ и проверяют только публичный API вашего крейта:
#![allow(unused)]
fn main() {
// tests/smoke_test.rs — без #[cfg(test)]
use my_crate::parse_config;
#[test]
fn parse_valid_config() {
let config = parse_config("test_data/valid.json").unwrap();
assert_eq!(config.max_retries, 5);
}
}
| Аспект | Модульные тесты (#[cfg(test)]) | Интеграционные тесты (tests/) |
|---|---|---|
| Расположение | Тот же файл, что и код | Отдельный каталог tests/ |
| Доступ | Приватные и публичные элементы | Только публичный API |
| Команда запуска | cargo test | cargo test --test smoke_test |
Паттерны и стратегии тестирования
Команды C-прошивок обычно пишут тесты на CUnit, CMocka или собственных фреймворках, с большим количеством шаблонного кода. Встроенный тестовый харнесс Rust гораздо мощнее. В этом разделе рассматриваются паттерны, которые понадобятся в продакшн-коде.
#[should_panic] — тестирование ожидаемых сбоев
#![allow(unused)]
fn main() {
// Проверяем, что определённые условия приводят к panic (как срабатывание assert в C)
#[test]
#[should_panic(expected = "index out of bounds")]
fn test_bounds_check() {
let v = vec![1, 2, 3];
let _ = v[10]; // Должно вызвать panic
}
#[test]
#[should_panic(expected = "temperature exceeds safe limit")]
fn test_thermal_shutdown() {
fn check_temperature(celsius: f64) {
if celsius > 105.0 {
panic!("temperature exceeds safe limit: {celsius}°C");
}
}
check_temperature(110.0);
}
}
#[ignore] — медленные тесты и тесты, зависящие от оборудования
#![allow(unused)]
fn main() {
// Помечаем тесты, которым нужны особые условия (как #ifdef HARDWARE_TEST в C)
#[test]
#[ignore = "requires GPU hardware"]
fn test_gpu_ecc_scrub() {
// Этот тест запускается только на машинах с GPU
// Запуск: cargo test -- --ignored
// Запуск: cargo test -- --include-ignored (запускает ВСЕ тесты)
}
}
Тесты, возвращающие Result (вместо цепочек unwrap)
#![allow(unused)]
fn main() {
// Вместо множества вызовов unwrap(), которые скрывают реальный сбой:
#[test]
fn test_config_parsing() -> Result<(), Box<dyn std::error::Error>> {
let json = r#"{"hostname": "node-01", "port": 8080}"#;
let config: ServerConfig = serde_json::from_str(json)?; // ? вместо unwrap()
assert_eq!(config.hostname, "node-01");
assert_eq!(config.port, 8080);
Ok(()) // Тест проходит, если мы дошли сюда без ошибок
}
}
Фикстуры тестов с функциями-билдерами
В C используют функции setUp()/tearDown(). В Rust — вспомогательные функции и Drop:
#![allow(unused)]
fn main() {
struct TestFixture {
temp_dir: std::path::PathBuf,
config: Config,
}
impl TestFixture {
fn new() -> Self {
let temp_dir = std::env::temp_dir().join(format!("test_{}", std::process::id()));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = Config {
log_dir: temp_dir.clone(),
max_retries: 3,
..Default::default()
};
Self { temp_dir, config }
}
}
impl Drop for TestFixture {
fn drop(&mut self) {
// Автоматическая очистка — как tearDown() в C, но её нельзя забыть вызвать
let _ = std::fs::remove_dir_all(&self.temp_dir);
}
}
#[test]
fn test_with_fixture() {
let fixture = TestFixture::new();
// Используем fixture.config, fixture.temp_dir...
assert!(fixture.temp_dir.exists());
// fixture автоматически уничтожается здесь → выполняется очистка
}
}
Мокирование трейтов для аппаратных интерфейсов
В C мокирование оборудования требует хитростей препроцессора или подмены указателей на функции. В Rust трейты делают это естественным:
#![allow(unused)]
fn main() {
// Продакшн-трейт для связи по IPMI
trait IpmiTransport {
fn send_command(&self, cmd: u8, data: &[u8]) -> Result<Vec<u8>, String>;
}
// Реальная реализация (используется в продакшне)
struct RealIpmi { /* Параметры подключения к BMC */ }
impl IpmiTransport for RealIpmi {
fn send_command(&self, cmd: u8, data: &[u8]) -> Result<Vec<u8>, String> {
// Реально обращается к аппаратуре BMC
todo!("Real IPMI call")
}
}
// Мок-реализация (используется в тестах)
struct MockIpmi {
responses: std::collections::HashMap<u8, Vec<u8>>,
}
impl IpmiTransport for MockIpmi {
fn send_command(&self, cmd: u8, _data: &[u8]) -> Result<Vec<u8>, String> {
self.responses.get(&cmd)
.cloned()
.ok_or_else(|| format!("No mock response for cmd 0x{cmd:02x}"))
}
}
// Обобщённая функция, которая работает и с реальной, и с мок-реализацией
fn read_sensor_temperature(transport: &dyn IpmiTransport) -> Result<f64, String> {
let response = transport.send_command(0x2D, &[])?;
if response.len() < 2 {
return Err("Response too short".into());
}
Ok(response[0] as f64 + (response[1] as f64 / 256.0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_temperature_reading() {
let mut mock = MockIpmi { responses: std::collections::HashMap::new() };
mock.responses.insert(0x2D, vec![72, 128]); // 72.5°C
let temp = read_sensor_temperature(&mock).unwrap();
assert!((temp - 72.5).abs() < 0.01);
}
#[test]
fn test_short_response() {
let mock = MockIpmi { responses: std::collections::HashMap::new() };
// Ответ не настроен → ошибка
assert!(read_sensor_temperature(&mock).is_err());
}
}
}
Property-based тестирование с proptest
Вместо проверки конкретных значений тестируйте свойства, которые должны выполняться всегда:
#![allow(unused)]
fn main() {
// Cargo.toml: [dev-dependencies] proptest = "1"
use proptest::prelude::*;
fn parse_sensor_id(s: &str) -> Option<u32> {
s.strip_prefix("sensor_")?.parse().ok()
}
fn format_sensor_id(id: u32) -> String {
format!("sensor_{id}")
}
proptest! {
#[test]
fn roundtrip_sensor_id(id in 0u32..10000) {
// Свойство: форматирование, а затем разбор должны вернуть исходное значение
let formatted = format_sensor_id(id);
let parsed = parse_sensor_id(&formatted);
prop_assert_eq!(parsed, Some(id));
}
#[test]
fn parse_rejects_garbage(s in "[^s].*") {
// Свойство: строки, не начинающиеся с 's', никогда не должны разбираться
let result = parse_sensor_id(&s);
prop_assert!(result.is_none());
}
}
}
Сравнение тестирования в C и Rust
| Тестирование в C | Аналог в Rust |
|---|---|
CUnit, CMocka, собственный фреймворк | Встроенный #[test] + cargo test |
setUp() / tearDown() | Функция-билдер + трейт Drop |
Mock-функции через #ifdef TEST | Внедрение зависимостей через трейты |
assert(x == y) | assert_eq!(x, y) с автоматическим выводом различий |
| Отдельный исполняемый файл для тестов | Тот же бинарный файл, условная компиляция через #[cfg(test)] |
valgrind --leak-check=full ./test | cargo test (безопасно по памяти по умолчанию) + cargo miri test |
Покрытие кода: gcov / lcov | cargo tarpaulin или cargo llvm-cov |
| Обнаружение тестов: ручная регистрация | Автоматическое — любая функция #[test] обнаруживается |
Паттерны тестирования
Паттерны тестирования для программистов C++
Что вы узнаете: встроенный тестовый фреймворк Rust —
#[test],#[should_panic], тесты, возвращающиеResult, паттерны-билдеры для тестовых данных, мокирование на основе трейтов, property-based тестирование сproptest, snapshot-тестирование сinstaи организацию интеграционных тестов. Тестирование без настройки, которое заменяет Google Test + CMake.
Тестирование в C++ обычно опирается на внешние фреймворки (Google Test, Catch2, Boost.Test) со сложной интеграцией в сборку. Тестовый фреймворк Rust встроен в язык и тулчейн — никаких зависимостей, никакой интеграции с CMake, никакой настройки тестового раннера.
Атрибуты тестов помимо #[test]
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_pass() {
assert_eq!(2 + 2, 4);
}
// Ожидаем panic — аналог EXPECT_DEATH в GTest
#[test]
#[should_panic]
fn out_of_bounds_panics() {
let v = vec![1, 2, 3];
let _ = v[10]; // Panic — тест проходит
}
// Ожидаем panic с определённой подстрокой в сообщении
#[test]
#[should_panic(expected = "index out of bounds")]
fn specific_panic_message() {
let v = vec![1, 2, 3];
let _ = v[10];
}
// Тесты, возвращающие Result<(), E> — используйте ? вместо unwrap()
#[test]
fn test_with_result() -> Result<(), String> {
let value: u32 = "42".parse().map_err(|e| format!("{e}"))?;
assert_eq!(value, 42);
Ok(())
}
// По умолчанию пропускаем медленные тесты — запуск через `cargo test -- --ignored`
#[test]
#[ignore]
fn slow_integration_test() {
std::thread::sleep(std::time::Duration::from_secs(10));
}
}
}
cargo test # Запустить все не игнорируемые тесты
cargo test -- --ignored # Запустить только игнорируемые тесты
cargo test -- --include-ignored # Запустить ВСЕ тесты, включая игнорируемые
cargo test test_name # Запустить тесты, имя которых соответствует шаблону
cargo test -- --nocapture # Показывать вывод println! во время тестов
cargo test -- --test-threads=1 # Запускать тесты последовательно (для общего состояния)
Вспомогательные функции для тестов: паттерн «строитель» для тестовых данных
В C++ вы бы использовали фикстуры Google Test (class MyTest : public ::testing::Test).
В Rust используйте функции-билдеры или трейт Default:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
// Функция-билдер — создаёт тестовые данные с разумными значениями по умолчанию
fn make_gpu_event(severity: Severity, fault_code: u32) -> DiagEvent {
DiagEvent {
source: "accel_diag".to_string(),
severity,
message: format!("Test event FC:{fault_code}"),
fault_code,
}
}
// Переиспользуемая фикстура — набор заранее созданных событий
fn sample_events() -> Vec<DiagEvent> {
vec![
make_gpu_event(Severity::Critical, 67956),
make_gpu_event(Severity::Warning, 32709),
make_gpu_event(Severity::Info, 10001),
]
}
#[test]
fn filter_critical_events() {
let events = sample_events();
let critical: Vec<_> = events.iter()
.filter(|e| e.severity == Severity::Critical)
.collect();
assert_eq!(critical.len(), 1);
assert_eq!(critical[0].fault_code, 67956);
}
}
}
Мокирование с помощью трейтов
В C++ для мокирования нужны фреймворки вроде Google Mock или ручные переопределения virtual-методов. В Rust вы определяете трейт для зависимости и подменяете реализации в тестах:
#![allow(unused)]
fn main() {
// Продакшн-трейт
trait SensorReader {
fn read_temperature(&self, sensor_id: u32) -> Result<f64, String>;
}
// Продакшн-реализация
struct HwSensorReader;
impl SensorReader for HwSensorReader {
fn read_temperature(&self, sensor_id: u32) -> Result<f64, String> {
// Реальный вызов оборудования...
Ok(72.5)
}
}
// Тестовый мок — возвращает предсказуемые значения
#[cfg(test)]
struct MockSensorReader {
temperatures: std::collections::HashMap<u32, f64>,
}
#[cfg(test)]
impl SensorReader for MockSensorReader {
fn read_temperature(&self, sensor_id: u32) -> Result<f64, String> {
self.temperatures.get(&sensor_id)
.copied()
.ok_or_else(|| format!("Unknown sensor {sensor_id}"))
}
}
// Тестируемая функция — обобщённая по типу reader
fn check_overtemp(reader: &impl SensorReader, ids: &[u32], threshold: f64) -> Vec<u32> {
ids.iter()
.filter(|&&id| reader.read_temperature(id).unwrap_or(0.0) > threshold)
.copied()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_overtemp_sensors() {
let mut mock = MockSensorReader { temperatures: Default::default() };
mock.temperatures.insert(0, 72.5);
mock.temperatures.insert(1, 91.0); // Выше порога
mock.temperatures.insert(2, 65.0);
let hot = check_overtemp(&mock, &[0, 1, 2], 80.0);
assert_eq!(hot, vec![1]);
}
}
}
Временные файлы и каталоги в тестах
В C++ тесты часто используют платформо-специфичные временные каталоги. В Rust есть крейт tempfile:
#![allow(unused)]
fn main() {
// Cargo.toml: [dev-dependencies]
// tempfile = "3"
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
use std::io::Write;
#[test]
fn parse_config_from_file() -> Result<(), Box<dyn std::error::Error>> {
// Создаём временный файл, который удаляется автоматически при уничтожении
let mut file = NamedTempFile::new()?;
writeln!(file, r#"{{"sku": "ServerNode", "level": "Quick"}}"#)?;
let config = load_config(file.path().to_str().unwrap())?;
assert_eq!(config.sku, "ServerNode");
Ok(())
// file удаляется здесь — код очистки не нужен
}
}
}
Property-based тестирование с proptest
Вместо написания конкретных тестовых случаев опишите свойства, которые должны выполняться
для всех входных данных. proptest генерирует случайные входные данные и находит минимальные падающие примеры:
#![allow(unused)]
fn main() {
// Cargo.toml: [dev-dependencies]
// proptest = "1"
#[cfg(test)]
mod tests {
use proptest::prelude::*;
fn parse_and_format(n: u32) -> String {
format!("{n}")
}
proptest! {
#[test]
fn roundtrip_u32(n: u32) {
let formatted = parse_and_format(n);
let parsed: u32 = formatted.parse().unwrap();
prop_assert_eq!(n, parsed);
}
#[test]
fn string_contains_no_null(s in "[a-zA-Z0-9 ]{0,100}") {
prop_assert!(!s.contains('\0'));
}
}
}
}
Snapshot-тестирование с insta
Для тестов, которые выдают сложный вывод (JSON, отформатированные строки), insta автоматически создаёт
и поддерживает эталонные снимки:
#![allow(unused)]
fn main() {
// Cargo.toml: [dev-dependencies]
// insta = { version = "1", features = ["json"] }
#[cfg(test)]
mod tests {
use insta::assert_json_snapshot;
#[test]
fn der_entry_format() {
let entry = DerEntry {
fault_code: 67956,
component: "GPU".to_string(),
message: "ECC error detected".to_string(),
};
// Первый запуск: создаёт файл снимка в tests/snapshots/
// Последующие запуски: сравнивают с сохранённым снимком
assert_json_snapshot!(entry);
}
}
}
cargo insta test # Запустить тесты и просмотреть новые/изменённые снимки
cargo insta review # Интерактивный просмотр изменений снимков
Сравнение тестирования C++ и Rust
| C++ (Google Test) | Rust | Примечания |
|---|---|---|
TEST(Suite, Name) { } | #[test] fn name() { } | Иерархия suite/class не нужна |
ASSERT_EQ(a, b) | assert_eq!(a, b) | Встроенный макрос, фреймворк не нужен |
ASSERT_NEAR(a, b, eps) | assert!((a - b).abs() < eps) | Или используйте крейт approx |
EXPECT_THROW(expr, type) | #[should_panic(expected = "...")] | Или catch_unwind для тонкого контроля |
EXPECT_DEATH(expr, "msg") | #[should_panic(expected = "msg")] | |
class Fixture : public ::testing::Test | Функции-билдеры + Default | Наследование не нужно |
Google Mock MOCK_METHOD | Трейт + тестовая реализация | Более явно, без магии макросов |
INSTANTIATE_TEST_SUITE_P (параметризованные) | proptest! или тесты, сгенерированные макросом | |
SetUp() / TearDown() | RAII через Drop — очистка автоматическая | Переменные уничтожаются в конце теста |
| Отдельный тестовый бинарник + CMake | cargo test — без настройки | |
ctest --output-on-failure | cargo test -- --nocapture |
Интеграционные тесты: каталог tests/
Модульные тесты находятся внутри модулей #[cfg(test)] рядом с вашим кодом. Интеграционные тесты находятся в отдельном каталоге tests/ в корне крейта и проверяют публичный API вашей библиотеки так, как это делал бы внешний потребитель:
my_crate/
├── src/
│ └── lib.rs # Код вашей библиотеки
├── tests/
│ ├── smoke.rs # Каждый .rs-файл — отдельный тестовый бинарник
│ ├── regression.rs
│ └── common/
│ └── mod.rs # Общие вспомогательные функции для тестов (НЕ сам тест)
└── Cargo.toml
#![allow(unused)]
fn main() {
// tests/smoke.rs — тестирует ваш крейт так, как это делал бы внешний пользователь
use my_crate::DiagEngine; // Доступен только публичный API
#[test]
fn engine_starts_successfully() {
let engine = DiagEngine::new("test_config.json");
assert!(engine.is_ok());
}
#[test]
fn engine_rejects_invalid_config() {
let engine = DiagEngine::new("nonexistent.json");
assert!(engine.is_err());
}
}
#![allow(unused)]
fn main() {
// tests/common/mod.rs — общие помощники, НЕ компилируются как тестовый бинарник
pub fn setup_test_environment() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("config.json"), r#"{"log_level": "debug"}"#).unwrap();
dir
}
}
#![allow(unused)]
fn main() {
// tests/regression.rs — может использовать общие помощники
mod common;
#[test]
fn regression_issue_42() {
let env = common::setup_test_environment();
let engine = my_crate::DiagEngine::new(
env.path().join("config.json").to_str().unwrap()
);
assert!(engine.is_ok());
}
}
Запуск интеграционных тестов:
cargo test # Запускает и модульные, и интеграционные тесты
cargo test --test smoke # Запустить только tests/smoke.rs
cargo test --test regression # Запустить только tests/regression.rs
cargo test --lib # Запустить ТОЛЬКО модульные тесты (без интеграционных)
Ключевое отличие от модульных тестов: интеграционные тесты не могут обращаться к приватным функциям или элементам
pub(crate). Это заставляет проверять, что публичного API достаточно — полезный сигнал для дизайна. В терминах C++ это похоже на тестирование только по публичному заголовку без доступаfriend.
9. Обработка ошибок
Связываем перечисления с Option и Result
Что вы узнаете: как Rust заменяет нулевые указатели на
Option<T>, а исключения — наResult<T, E>, и как оператор?делает распространение ошибок кратким. Это самый характерный паттерн Rust: ошибки — это значения, а не скрытый поток управления.
- Помните тип
enum, который мы изучали раньше?OptionиResultв Rust — это просто перечисления, определённые в стандартной библиотеке:
#![allow(unused)]
fn main() {
// Именно так Option определён в std:
enum Option<T> {
Some(T), // Содержит значение
None, // Значения нет
}
// А вот Result:
enum Result<T, E> {
Ok(T), // Успех со значением
Err(E), // Ошибка с подробностями
}
}
- Это значит, что всё, что вы знаете о сопоставлении с образцом через
match, напрямую работает сOptionиResult - В Rust нет нулевых указателей — их замена это
Option<T>, и компилятор заставляет вас обработать случайNone
Сравнение с C++: исключения и Result
| Шаблон C++ | Аналог в Rust | Преимущество |
|---|---|---|
throw std::runtime_error(msg) | Err(MyError::Runtime(msg)) | Ошибка в типе возвращаемого значения — её нельзя забыть обработать |
try { } catch (...) { } | match result { Ok(v) => ..., Err(e) => ... } | Никакого скрытого потока управления |
std::optional<T> | Option<T> | Требуется исчерпывающее сопоставление — нельзя забыть про None |
Аннотация noexcept | По умолчанию — все функции Rust «noexcept» | Исключений не существует |
errno / коды возврата | Result<T, E> | Типобезопасно, игнорировать нельзя |
Тип Option в Rust
- Тип
Optionв Rust — этоenumвсего с двумя вариантами:Some<T>иNone- Идея в том, что он представляет
nullableтип, то есть либо содержит корректное значение этого типа (Some<T>), либо не содержит значения (None) - Тип
Optionиспользуется в API там, где результат операции либо успешен и возвращает корректное значение, либо завершается неудачей (причина ошибки при этом неважна). Например, разбор строки в целое число
- Идея в том, что он представляет
fn main() {
// Возвращает Option<usize>
let a = "1234".find("1");
match a {
Some(a) => println!("Found 1 at index {a}"),
None => println!("Couldn't find 1")
}
}
Работа с Option в Rust
Optionв Rust можно обрабатывать разными способамиunwrap()вызывает panic, еслиOption<T>равенNone, и возвращаетTв противном случае; это наименее предпочтительный способor()позволяет вернуть альтернативное значениеif letпозволяет проверить наличиеSome<T>
Промышленные шаблоны: реальные примеры из продакшн-кода на Rust — Безопасное извлечение значения с unwrap_or и Функциональные преобразования: map, map_err, find_map.
fn main() {
// Это возвращает Option<usize>
let a = "1234".find("1");
println!("{a:?} {}", a.unwrap());
let a = "1234".find("5").or(Some(42));
println!("{a:?}");
if let Some(a) = "1234".find("1") {
println!("{a}");
} else {
println!("Not found in string");
}
// Это вызовет panic
// "1234".find("5").unwrap();
}
Тип Result в Rust
- Result — это тип
enumпохожий наOption, с двумя вариантами:Ok<T>илиErr<E>Resultшироко используется в API Rust, которые могут завершиться неудачей. Идея в том, что при успехе функции вернутOk<T>, а при ошибке — конкретную ошибкуErr<T>
use std::num::ParseIntError;
fn main() {
let a : Result<i32, ParseIntError> = "1234z".parse();
match a {
Ok(n) => println!("Parsed {n}"),
Err(e) => println!("Parsing failed {e:?}"),
}
let a : Result<i32, ParseIntError> = "1234z".parse().or(Ok(-1));
println!("{a:?}");
if let Ok(a) = "1234".parse::<i32>() {
println!("Let OK {a}");
}
// Это вызовет panic
//"1234z".parse().unwrap();
}
Option и Result: две стороны одной медали
Option и Result тесно связаны — Option<T> по сути является Result<T, ()> (результатом, в котором ошибка не несёт никакой информации):
Option<T> | Result<T, E> | Значение |
|---|---|---|
Some(value) | Ok(value) | Успех — значение есть |
None | Err(error) | Неудача — значения нет (Option) или подробности ошибки (Result) |
Преобразование между ними:
fn main() {
let opt: Option<i32> = Some(42);
let res: Result<i32, &str> = opt.ok_or("value was None"); // Option → Result
let res: Result<i32, &str> = Ok(42);
let opt: Option<i32> = res.ok(); // Result → Option (отбрасывает ошибку)
// У них много общих методов:
// .map(), .and_then(), .unwrap_or(), .unwrap_or_else(), .is_some()/is_ok()
}
Эмпирическое правило: используйте
Option, когда отсутствие значения — это нормально (например, поиск по ключу). ИспользуйтеResult, когда неудаче нужно объяснение (например, операции ввода-вывода с файлами, разбор).
Упражнение: реализация функции log() с Option
🟢 Начальный уровень
- Реализуйте функцию
log(), которая принимает параметрOption<&str>. Если параметр равенNone, она должна выводить строку по умолчанию - Функция должна возвращать
Resultс()и для успеха, и для ошибки (в этом случае ошибка никогда не возникнет)
Решение (нажмите, чтобы развернуть)
fn log(message: Option<&str>) -> Result<(), ()> {
match message {
Some(msg) => println!("LOG: {msg}"),
None => println!("LOG: (no message provided)"),
}
Ok(())
}
fn main() {
let _ = log(Some("System initialized"));
let _ = log(None);
// Альтернатива с unwrap_or:
let msg: Option<&str> = None;
println!("LOG: {}", msg.unwrap_or("(default message)"));
}
// Вывод:
// LOG: System initialized
// LOG: (no message provided)
// LOG: (default message)
Обработка ошибок в Rust
- Ошибки в Rust бывают неустранимыми (фатальными) и устранимыми. Фатальные ошибки приводят к
panic- В целом ситуаций, которые приводят к
panic, следует избегать.panicвызывается ошибками в программе, включая выход за границы индекса, вызовunwrap()дляOption<None>и т. д. - Явные
panicдопустимы для условий, которые по замыслу невозможны. Макросыpanic!илиassert!можно использовать для проверок здравого смысла
- В целом ситуаций, которые приводят к
fn main() {
let x : Option<u32> = None;
// println!("{x}", x.unwrap()); // Вызовет panic
println!("{}", x.unwrap_or(0)); // OK — выводит 0
let x = 41;
//assert!(x == 42); // Вызовет panic
//panic!("Something went wrong"); // Безусловный panic
let _a = vec![0, 1];
// println!("{}", a[2]); // Panic из-за выхода за границы; используйте a.get(2), который вернёт Option<T>
}
Обработка ошибок: C++ и Rust
Проблемы обработки ошибок на исключениях в C++
// Обработка ошибок в C++ — исключения создают скрытый поток управления
#include <fstream>
#include <stdexcept>
std::string read_config(const std::string& path) {
std::ifstream file(path);
if (!file.is_open()) {
throw std::runtime_error("Cannot open: " + path);
}
std::string content;
// Что, если getline выбросит исключение? Закроется ли файл правильно?
// С RAII — да, но как насчёт других ресурсов?
std::getline(file, content);
return content; // Что, если вызывающий код не использует try/catch?
}
int main() {
// ОШИБКА: забыли обернуть в try/catch!
auto config = read_config("nonexistent.txt");
// Исключение распространяется молча, программа падает
// В сигнатуре функции ничего не предупредило нас
return 0;
}
graph TD
subgraph "Проблемы обработки ошибок в C++"
CF["Вызов функции"]
CR["throw исключение<br/>или код возврата"]
CIGNORE["[ОШИБКА] Исключение не перехвачено<br/>или код возврата проигнорирован"]
CCHECK["try/catch или проверка"]
CERROR["Скрытый поток управления<br/>throw не отражён в сигнатуре"]
CERRNO["Нет принудительной проверки<br/>на этапе компиляции"]
CF --> CR
CR --> CIGNORE
CR --> CCHECK
CCHECK --> CERROR
CERROR --> CERRNO
CPROBLEMS["[ОШИБКА] Исключения не видны в типах<br/>[ОШИБКА] Скрытый поток управления<br/>[ОШИБКА] Легко забыть try/catch<br/>[ОШИБКА] Безопасность исключений трудна<br/>[ОШИБКА] noexcept — опционален"]
end
subgraph "Система Result<T, E> в Rust"
RF["Вызов функции"]
RR["Result<T, E><br/>Ok(value) | Err(error)"]
RMUST["[OK] Нужно обработать<br/>Ошибка компиляции, если проигнорировано"]
RMATCH["Сопоставление с образцом<br/>match, if let, ?"]
RDETAIL["Подробная информация об ошибке<br/>Пользовательские типы ошибок"]
RSAFE["Типобезопасно<br/>Нет глобального состояния"]
RF --> RR
RR --> RMUST
RMUST --> RMATCH
RMATCH --> RDETAIL
RDETAIL --> RSAFE
RBENEFITS["[OK] Принудительная обработка ошибок<br/>[OK] Типобезопасные ошибки<br/>[OK] Подробная информация об ошибке<br/>[OK] Компонуется с помощью ?<br/>[OK] Нулевая стоимость во время выполнения"]
end
style CPROBLEMS fill:#ff6b6b,color:#000
style RBENEFITS fill:#91e5a3,color:#000
style CIGNORE fill:#ff6b6b,color:#000
style RMUST fill:#91e5a3,color:#000
Визуализация Result<T, E>
// Обработка ошибок в Rust — полная и обязательная
use std::fs::File;
use std::io::Read;
fn read_file_content(filename: &str) -> Result<String, std::io::Error> {
let mut file = File::open(filename)?; // ? автоматически распространяет ошибки
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents) // Случай успеха
}
fn main() {
match read_file_content("example.txt") {
Ok(content) => println!("File content: {}", content),
Err(error) => println!("Failed to read file: {}", error),
// Компилятор заставляет обработать оба случая!
}
}
graph TD
subgraph "Поток Result<T, E>"
START["Начало функции"]
OP1["File::open()"]
CHECK1{{"Проверка Result"}}
OP2["file.read_to_string()"]
CHECK2{{"Проверка Result"}}
SUCCESS["Ok(contents)"]
ERROR1["Err(io::Error)"]
ERROR2["Err(io::Error)"]
START --> OP1
OP1 --> CHECK1
CHECK1 -->|"Ok(file)"| OP2
CHECK1 -->|"Err(e)"| ERROR1
OP2 --> CHECK2
CHECK2 -->|"Ok(())"| SUCCESS
CHECK2 -->|"Err(e)"| ERROR2
ERROR1 --> PROPAGATE["Оператор ?<br/>распространяет ошибку"]
ERROR2 --> PROPAGATE
PROPAGATE --> CALLER["Вызывающий код должен<br/>обработать ошибку"]
end
subgraph "Варианты сопоставления с образцом"
MATCH["match result"]
IFLET["if let Ok(val) = result"]
UNWRAP["result.unwrap()<br/>[ВНИМАНИЕ] Panic при ошибке"]
EXPECT["result.expect(msg)<br/>[ВНИМАНИЕ] Panic с сообщением"]
UNWRAP_OR["result.unwrap_or(default)<br/>[OK] Безопасный запасной вариант"]
QUESTION["result?<br/>[OK] Ранний возврат"]
MATCH --> SAFE1["[OK] Обрабатывает оба случая"]
IFLET --> SAFE2["[OK] Обрабатывает случай ошибки"]
UNWRAP_OR --> SAFE3["[OK] Всегда возвращает значение"]
QUESTION --> SAFE4["[OK] Передаёт ошибку вызывающему"]
UNWRAP --> UNSAFE1["[ОШИБКА] Может вызвать panic"]
EXPECT --> UNSAFE2["[ОШИБКА] Может вызвать panic"]
end
style SUCCESS fill:#91e5a3,color:#000
style ERROR1 fill:#ffa07a,color:#000
style ERROR2 fill:#ffa07a,color:#000
style SAFE1 fill:#91e5a3,color:#000
style SAFE2 fill:#91e5a3,color:#000
style SAFE3 fill:#91e5a3,color:#000
style SAFE4 fill:#91e5a3,color:#000
style UNSAFE1 fill:#ff6b6b,color:#000
style UNSAFE2 fill:#ff6b6b,color:#000
Обработка ошибок в Rust
- Rust использует перечисление
enum Result<T, E>для обработки устранимых ошибок- Вариант
Ok<T>содержит результат в случае успеха, аErr<E>содержит ошибку
- Вариант
fn main() {
let x = "1234x".parse::<u32>();
match x {
Ok(x) => println!("Parsed number {x}"),
Err(e) => println!("Parsing error {e:?}"),
}
let x = "1234".parse::<u32>();
// То же, что и выше, но с корректным числом
if let Ok(x) = &x {
println!("Parsed number {x}")
} else if let Err(e) = &x {
println!("Error: {e:?}");
}
}
Оператор ? в Rust
- Оператор
?— это удобная краткая запись для шаблонаmatchс веткамиOk/Err- Обратите внимание: метод должен возвращать
Result<T, E>, чтобы можно было использовать? - Тип
Result<T, E>можно менять. В примере ниже мы возвращаем тот же тип ошибки (std::num::ParseIntError), который возвращаетstr::parse()
- Обратите внимание: метод должен возвращать
fn double_string_number(s : &str) -> Result<u32, std::num::ParseIntError> {
let x = s.parse::<u32>()?; // Немедленно возвращает результат в случае ошибки
Ok(x*2)
}
fn main() {
let result = double_string_number("1234");
println!("{result:?}");
let result = double_string_number("1234x");
println!("{result:?}");
}
Преобразование ошибок в Rust
- Ошибки можно преобразовывать в другие типы или в значения по умолчанию (https://doc.rust-lang.org/std/result/enum.Result.html#method.unwrap_or_default)
#![allow(unused)]
fn main() {
// Меняет тип ошибки на () в случае ошибки
fn double_string_number(s : &str) -> Result<u32, ()> {
let x = s.parse::<u32>().map_err(|_|())?; // Немедленно возвращает результат в случае ошибки
Ok(x*2)
}
}
#![allow(unused)]
fn main() {
fn double_string_number(s : &str) -> Result<u32, ()> {
let x = s.parse::<u32>().unwrap_or_default(); // По умолчанию 0 в случае ошибки разбора
Ok(x*2)
}
}
#![allow(unused)]
fn main() {
fn double_optional_number(x : Option<u32>) -> Result<u32, ()> {
// ok_or преобразует Option<None> в Result<u32, ()> в этом примере
x.ok_or(()).map(|x|x*2) // .map() применяется только к Ok(u32)
}
}
Упражнение: обработка ошибок
🟡 Средний уровень
- Реализуйте функцию
log()с одним параметром u32. Если параметр не равен 42, верните ошибку. ТипResult<>для успеха и ошибки —() - Вызовите функцию
log()так, чтобы она сама завершалась с тем же типомResult<>, еслиlog()вернула ошибку. В противном случае выведите сообщение, что log был успешно вызван
fn log(x: u32) -> ?? {
}
fn call_log(x: u32) -> ?? {
// Вызовите log(x), затем немедленно выйдите, если она вернула ошибку
println!("log was successfully called");
}
fn main() {
call_log(42);
call_log(43);
}
Решение (нажмите, чтобы развернуть)
fn log(x: u32) -> Result<(), ()> {
if x == 42 {
Ok(())
} else {
Err(())
}
}
fn call_log(x: u32) -> Result<(), ()> {
log(x)?; // Немедленно выходим, если log() вернула ошибку
println!("log was successfully called with {x}");
Ok(())
}
fn main() {
let _ = call_log(42); // Выводит: log was successfully called with 42
let _ = call_log(43); // Возвращает Err(()), ничего не выводится
}
// Вывод:
// log was successfully called with 42
Ключевые выводы по Option и Result в Rust
Что вы узнаете: идиоматические паттерны обработки ошибок — безопасные альтернативы
unwrap(), оператор?для распространения ошибок, пользовательские типы ошибок и когда использоватьanyhow, а когдаthiserrorв продакшн-коде.
OptionиResult— неотъемлемая часть идиоматичного Rust- Безопасные альтернативы
unwrap():
#![allow(unused)]
fn main() {
// Безопасные альтернативы для Option<T>
let value = opt.unwrap_or(default); // Запасное значение
let value = opt.unwrap_or_else(|| compute()); // Ленивое вычисление запасного значения
let value = opt.unwrap_or_default(); // Использовать реализацию трейта Default
let value = opt.expect("descriptive message"); // Только когда panic допустим
// Безопасные альтернативы для Result<T, E>
let value = result.unwrap_or(fallback); // Игнорировать ошибку, использовать запасное значение
let value = result.unwrap_or_else(|e| handle(e)); // Обработать ошибку и вернуть запасное значение
let value = result.unwrap_or_default(); // Использовать трейт Default
}
- Сопоставление с образцом для явного контроля:
#![allow(unused)]
fn main() {
match some_option {
Some(value) => println!("Got: {}", value),
None => println!("No value found"),
}
match some_result {
Ok(value) => process(value),
Err(error) => log_error(error),
}
}
- Используйте оператор
?для распространения ошибок: досрочный выход и передача ошибок наверх
#![allow(unused)]
fn main() {
fn process_file(path: &str) -> Result<String, std::io::Error> {
let content = std::fs::read_to_string(path)?; // Автоматически возвращает ошибку
Ok(content.to_uppercase())
}
}
- Методы преобразования:
map(): преобразует успешное значениеOk(T)->Ok(U)илиSome(T)->Some(U)map_err(): преобразует тип ошибкиErr(E)->Err(F)and_then(): объединяет цепочкой операции, которые могут завершиться неудачей
- Используйте в собственных API: предпочитайте
Result<T, E>исключениям или кодам ошибок - Ссылки: документация Option | документация Result
Частые ошибки Rust и советы по отладке
- Проблемы с заимствованием: самая распространённая ошибка новичков
- “cannot borrow as mutable” -> одновременно допускается только одна изменяемая ссылка
- “borrowed value does not live long enough” -> ссылка переживает данные, на которые указывает
- Решение: используйте области видимости
{}, чтобы ограничить время жизни ссылок, или клонируйте данные при необходимости
- Отсутствующие реализации трейтов: ошибки “method not found”
- Решение: добавьте
#[derive(Debug, Clone, PartialEq)]для распространённых трейтов - Используйте
cargo check, чтобы получать более понятные сообщения об ошибках, чем приcargo run
- Решение: добавьте
- Переполнение целых в отладочной сборке: Rust вызывает panic при переполнении
- Решение: используйте
wrapping_add(),saturating_add()илиchecked_add()для явного поведения
- Решение: используйте
- Путаница между String и &str: разные типы для разных задач
- Используйте
&strдля срезов строк (заимствованных),String— для владеющих строк - Решение: используйте
.to_string()илиString::from(), чтобы преобразовать&strвString
- Используйте
- Борьба с проверкой заимствований: не пытайтесь её перехитрить
- Решение: перестройте код так, чтобы он работал с правилами владения, а не против них
- Для сложных случаев совместного использования рассмотрите
Rc<RefCell<T>>(но умеренно)
Примеры обработки ошибок: хорошо и плохо
#![allow(unused)]
fn main() {
// [ОШИБКА] ПЛОХО: может неожиданно вызвать panic
fn bad_config_reader() -> String {
let config = std::env::var("CONFIG_FILE").unwrap(); // Panic, если не задано!
std::fs::read_to_string(config).unwrap() // Panic, если файла нет!
}
// [OK] ХОРОШО: обрабатывает ошибки корректно
fn good_config_reader() -> Result<String, ConfigError> {
let config_path = std::env::var("CONFIG_FILE")
.unwrap_or_else(|_| "default.conf".to_string()); // Запасной вариант по умолчанию
let content = std::fs::read_to_string(config_path)
.map_err(ConfigError::FileRead)?; // Преобразовать и передать ошибку дальше
Ok(content)
}
// [OK] ЕЩЁ ЛУЧШЕ: с правильными типами ошибок
use thiserror::Error;
#[derive(Error, Debug)]
enum ConfigError {
#[error("Failed to read config file: {0}")]
FileRead(#[from] std::io::Error),
#[error("Invalid configuration: {message}")]
Invalid { message: String },
}
}
Разберём, что здесь происходит. У ConfigError всего два варианта — один для ошибок ввода-вывода и один для ошибок валидации. Это правильная отправная точка для большинства модулей:
Вариант ConfigError | Содержит | Создаётся |
|---|---|---|
FileRead(io::Error) | Исходную ошибку ввода-вывода | #[from] автоматически преобразует через ? |
Invalid { message } | Понятное человеку объяснение | Ваш код валидации |
Теперь можно писать функции, которые возвращают Result<T, ConfigError>:
#![allow(unused)]
fn main() {
fn read_config(path: &str) -> Result<String, ConfigError> {
let content = std::fs::read_to_string(path)?; // io::Error → ConfigError::FileRead
if content.is_empty() {
return Err(ConfigError::Invalid {
message: "config file is empty".to_string(),
});
}
Ok(content)
}
}
🟢 Контрольная точка для самостоятельного изучения: прежде чем продолжить, убедитесь, что можете ответить:
- Почему
?после вызоваread_to_stringработает? (Потому что#[from]генерируетimpl From<io::Error> for ConfigError)- Что произойдёт, если добавить третий вариант
MissingKey(String)— какой код придётся менять? (Просто добавить вариант; существующий код по-прежнему компилируется)
Типы ошибок на уровне крейта и псевдонимы Result
По мере роста проекта за пределы одного файла вы будете объединять ошибки отдельных модулей в тип ошибок на уровне крейта. Это стандартный паттерн в продакшн-коде на Rust. Построим его на основе ConfigError выше.
В реальных проектах на Rust каждый крейт (или значимый модуль) определяет собственное перечисление Error
и псевдоним типа Result. Это идиоматичный паттерн — аналогичный тому, как в C++
вы определяли бы иерархию исключений для библиотеки и using Result = std::expected<T, Error>.
Шаблон
#![allow(unused)]
fn main() {
// src/error.rs (или в начале lib.rs)
use thiserror::Error;
/// Все ошибки, которые может породить этот крейт.
#[derive(Error, Debug)]
pub enum Error {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error), // автоматически преобразуется через From
#[error("JSON parse error: {0}")]
Json(#[from] serde_json::Error), // автоматически преобразуется через From
#[error("Invalid sensor id: {0}")]
InvalidSensor(u32), // специфичный для предметной области вариант
#[error("Timeout after {ms} ms")]
Timeout { ms: u64 },
}
/// Псевдоним Result на уровне всего крейта — экономит набор текста во всём крейте.
pub type Result<T> = core::result::Result<T, Error>;
}
Как это упрощает каждую функцию
Без псевдонима вы бы писали:
#![allow(unused)]
fn main() {
// Многословно — тип ошибки повторяется везде
fn read_sensor(id: u32) -> Result<f64, crate::Error> { ... }
fn parse_config(path: &str) -> Result<Config, crate::Error> { ... }
}
С псевдонимом:
#![allow(unused)]
fn main() {
// Чисто — просто `Result<T>`
use crate::{Error, Result};
fn read_sensor(id: u32) -> Result<f64> {
if id > 128 {
return Err(Error::InvalidSensor(id));
}
let raw = std::fs::read_to_string(format!("/dev/sensor/{id}"))?; // io::Error → Error::Io
let value: f64 = raw.trim().parse()
.map_err(|_| Error::InvalidSensor(id))?;
Ok(value)
}
}
Атрибут #[from] на Io генерирует эту реализацию бесплатно:
#![allow(unused)]
fn main() {
// Генерируется автоматически атрибутом #[from] из thiserror
impl From<std::io::Error> for Error {
fn from(source: std::io::Error) -> Self {
Error::Io(source)
}
}
}
Именно это делает ? работающим: когда функция возвращает std::io::Error, а ваша функция
возвращает Result<T> (ваш псевдоним), компилятор вызывает From::from(), чтобы автоматически
преобразовать ошибку.
Композиция ошибок отдельных модулей
В больших крейтах ошибки разделяют по модулям, а затем объединяют в корне крейта:
#![allow(unused)]
fn main() {
// src/config/error.rs
#[derive(thiserror::Error, Debug)]
pub enum ConfigError {
#[error("Missing key: {0}")]
MissingKey(String),
#[error("Invalid value for '{key}': {reason}")]
InvalidValue { key: String, reason: String },
}
// src/error.rs (уровень крейта)
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error(transparent)] // делегирует Display внутренней ошибке
Config(#[from] crate::config::ConfigError),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
pub type Result<T> = core::result::Result<T, Error>;
}
Вызывающий код по-прежнему может сопоставлять конкретные ошибки конфигурации:
#![allow(unused)]
fn main() {
match result {
Err(Error::Config(ConfigError::MissingKey(k))) => eprintln!("Add '{k}' to config"),
Err(e) => eprintln!("Other error: {e}"),
Ok(v) => use_value(v),
}
}
Сравнение с C++
| Понятие | C++ | Rust |
|---|---|---|
| Иерархия ошибок | class AppError : public std::runtime_error | #[derive(thiserror::Error)] enum Error { ... } |
| Возврат ошибки | std::expected<T, Error> или throw | fn foo() -> Result<T> |
| Преобразование ошибки | Ручной try/catch + повторный throw | #[from] + ? — без шаблонного кода |
| Псевдоним Result | template<class T> using Result = std::expected<T, Error>; | pub type Result<T> = core::result::Result<T, Error>; |
| Сообщение об ошибке | Переопределить what() | #[error("...")] — компилируется в реализацию Display |
Трейты в Rust
Что вы узнаете: трейты — ответ Rust на интерфейсы, абстрактные базовые классы и перегрузку операторов. Вы научитесь определять трейты, реализовывать их для своих типов и использовать динамическую диспетчеризацию (
dyn Trait) или статическую (обобщения). Для программистов C++: трейты заменяют виртуальные функции, CRTP и концепты. Для программистов на C: трейты — структурированный способ полиморфизма в Rust.
- Трейты в Rust похожи на интерфейсы в других языках
- Трейты определяют методы, которые должны быть реализованы типами, реализующими трейт.
fn main() {
trait Pet {
fn speak(&self);
}
struct Cat;
struct Dog;
impl Pet for Cat {
fn speak(&self) {
println!("Meow");
}
}
impl Pet for Dog {
fn speak(&self) {
println!("Woof!")
}
}
let c = Cat{};
let d = Dog{};
c.speak(); // Между Cat и Dog нет отношения «является»
d.speak(); // Между Cat и Dog нет отношения «является»
}
Трейты в сравнении с концептами и интерфейсами C++
Традиционное наследование в C++ и трейты Rust
// C++ — полиморфизм на основе наследования
class Animal {
public:
virtual void speak() = 0; // Чисто виртуальная функция
virtual ~Animal() = default;
};
class Cat : public Animal { // «Cat ЯВЛЯЕТСЯ Animal»
public:
void speak() override {
std::cout << "Meow" << std::endl;
}
};
void make_sound(Animal* animal) { // Полиморфизм времени выполнения
animal->speak(); // Вызов виртуальной функции
}
#![allow(unused)]
fn main() {
// Rust — композиция вместо наследования, через трейты
trait Animal {
fn speak(&self);
}
struct Cat; // Cat НЕ является Animal, но РЕАЛИЗУЕТ поведение Animal
impl Animal for Cat { // «Cat УМЕЕТ поведение Animal»
fn speak(&self) {
println!("Meow");
}
}
fn make_sound<T: Animal>(animal: &T) { // Статический полиморфизм
animal.speak(); // Прямой вызов функции (без накладных расходов)
}
}
graph TD
subgraph "Объектно-ориентированная иерархия C++"
CPP_ANIMAL["Animal<br/>(абстрактный базовый класс)"]
CPP_CAT["Cat : public Animal<br/>(отношение IS-A)"]
CPP_DOG["Dog : public Animal<br/>(отношение IS-A)"]
CPP_ANIMAL --> CPP_CAT
CPP_ANIMAL --> CPP_DOG
CPP_VTABLE["Таблица виртуальных функций<br/>(диспетчеризация во время выполнения)"]
CPP_HEAP["Часто требует<br/>выделения в куче"]
CPP_ISSUES["[ОШИБКА] Глубокие деревья наследования<br/>[ОШИБКА] Проблема ромба<br/>[ОШИБКА] Накладные расходы во время выполнения<br/>[ОШИБКА] Жёсткая связанность"]
end
subgraph "Композиция на трейтах в Rust"
RUST_TRAIT["trait Animal<br/>(определение поведения)"]
RUST_CAT["struct Cat<br/>(только данные)"]
RUST_DOG["struct Dog<br/>(только данные)"]
RUST_CAT -.->|"impl Animal for Cat<br/>(УМЕЕТ поведение)"| RUST_TRAIT
RUST_DOG -.->|"impl Animal for Dog<br/>(УМЕЕТ поведение)"| RUST_TRAIT
RUST_STATIC["Статическая диспетчеризация<br/>(на этапе компиляции)"]
RUST_STACK["Возможно размещение<br/>на стеке"]
RUST_BENEFITS["[OK] Нет иерархии наследования<br/>[OK] Несколько реализаций трейтов<br/>[OK] Нет накладных расходов во время выполнения<br/>[OK] Слабая связанность"]
end
style CPP_ISSUES fill:#ff6b6b,color:#000
style RUST_BENEFITS fill:#91e5a3,color:#000
style CPP_VTABLE fill:#ffa07a,color:#000
style RUST_STATIC fill:#91e5a3,color:#000
Ограничения трейтов и обобщённые ограничения
#![allow(unused)]
fn main() {
use std::fmt::Display;
use std::ops::Add;
// Аналог на C++ с шаблонами (меньше ограничений)
// template<typename T>
// T add_and_print(T a, T b) {
// // Нет гарантии, что T поддерживает + или вывод
// return a + b; // Может не скомпилироваться
// }
// Rust — явные ограничения трейтами
fn add_and_print<T>(a: T, b: T) -> T
where
T: Display + Add<Output = T> + Copy,
{
println!("Adding {} + {}", a, b); // Трейт Display
a + b // Трейт Add
}
}
graph TD
subgraph "Эволюция обобщённых ограничений"
UNCONSTRAINED["fn process<T>(data: T)<br/>[ОШИБКА] T может быть чем угодно"]
SINGLE_BOUND["fn process<T: Display>(data: T)<br/>[OK] T должен реализовывать Display"]
MULTI_BOUND["fn process<T>(data: T)<br/>where T: Display + Clone + Debug<br/>[OK] Несколько требований"]
UNCONSTRAINED --> SINGLE_BOUND
SINGLE_BOUND --> MULTI_BOUND
end
subgraph "Синтаксис ограничений трейтов"
INLINE["fn func<T: Trait>(param: T)"]
WHERE_CLAUSE["fn func<T>(param: T)<br/>where T: Trait"]
IMPL_PARAM["fn func(param: impl Trait)"]
COMPARISON["Inline: простые случаи<br/>Where: сложные ограничения<br/>impl: краткий синтаксис"]
end
subgraph "Магия времени компиляции"
GENERIC_FUNC["Обобщённая функция<br/>с ограничениями трейтов"]
TYPE_CHECK["Компилятор проверяет<br/>реализации трейтов"]
MONOMORPH["Мономорфизация<br/>(создание специализированных версий)"]
OPTIMIZED["Полностью оптимизированный<br/>машинный код"]
GENERIC_FUNC --> TYPE_CHECK
TYPE_CHECK --> MONOMORPH
MONOMORPH --> OPTIMIZED
EXAMPLE["add_and_print::<i32><br/>add_and_print::<f64><br/>(генерируются отдельные функции)"]
MONOMORPH --> EXAMPLE
end
style UNCONSTRAINED fill:#ff6b6b,color:#000
style SINGLE_BOUND fill:#ffa07a,color:#000
style MULTI_BOUND fill:#91e5a3,color:#000
style OPTIMIZED fill:#91e5a3,color:#000
Перегрузка операторов в C++ → трейты std::ops в Rust
В C++ операторы перегружают, записывая свободные функции или методы с особыми именами (operator+, operator<<, operator[] и т. д.). В Rust каждый оператор соответствует трейту из std::ops (или std::fmt для вывода). Вы реализуете трейт вместо того, чтобы писать функцию с магическим именем.
Рядом: оператор +
// C++: перегрузка оператора как метода или свободной функции
struct Vec2 {
double x, y;
Vec2 operator+(const Vec2& rhs) const {
return {x + rhs.x, y + rhs.y};
}
};
Vec2 a{1.0, 2.0}, b{3.0, 4.0};
Vec2 c = a + b; // вызывает a.operator+(b)
#![allow(unused)]
fn main() {
use std::ops::Add;
#[derive(Debug, Clone, Copy)]
struct Vec2 { x: f64, y: f64 }
impl Add for Vec2 {
type Output = Vec2; // Ассоциированный тип — результат +
fn add(self, rhs: Vec2) -> Vec2 {
Vec2 { x: self.x + rhs.x, y: self.y + rhs.y }
}
}
let a = Vec2 { x: 1.0, y: 2.0 };
let b = Vec2 { x: 3.0, y: 4.0 };
let c = a + b; // вызывает <Vec2 as Add>::add(a, b)
println!("{c:?}"); // Vec2 { x: 4.0, y: 6.0 }
}
Ключевые отличия от C++
| Аспект | C++ | Rust |
|---|---|---|
| Механизм | Магические имена функций (operator+) | Реализация трейта (impl Add for T) |
| Поиск | Grep по operator+ или чтение заголовка | Смотрите реализации трейтов — отличная поддержка в IDE |
| Тип результата | Выбирается свободно | Фиксируется ассоциированным типом Output |
| Получатель | Обычно принимает const T& (заимствование) | По умолчанию принимает self по значению (перемещение!) |
| Симметрия | Можно написать impl operator+(int, Vec2) | Нужно добавить impl Add<Vec2> for i32 (действуют правила для чужих трейтов) |
<< для вывода | operator<<(ostream&, T) — перегрузка для любого потока | impl fmt::Display for T — одно каноническое представление to_string |
Ловушка self по значению
В Rust Add::add(self, rhs) принимает self по значению. Для типов Copy (как Vec2 выше, у которого есть derive(Copy)) это нормально — компилятор копирует. Но для типов, не реализующих Copy, + поглощает операнды:
#![allow(unused)]
fn main() {
let s1 = String::from("hello ");
let s2 = String::from("world");
let s3 = s1 + &s2; // s1 ПЕРЕМЕЩЁН в s3!
// println!("{s1}"); // ❌ Ошибка компиляции: значение использовано после перемещения
println!("{s2}"); // ✅ s2 была только заимствована (&s2)
}
Именно поэтому String + &str работает, а &str + &str — нет: Add реализован только для String + &str, и он поглощает левую String, чтобы переиспользовать её буфер. В C++ этому нет аналога: std::string::operator+ всегда создаёт новую строку.
Полное соответствие: операторы C++ → трейты Rust
| Оператор C++ | Трейт Rust | Примечания |
|---|---|---|
operator+ | std::ops::Add | Ассоциированный тип Output |
operator- | std::ops::Sub | |
operator* | std::ops::Mul | Не разыменование указателя — это Deref |
operator/ | std::ops::Div | |
operator% | std::ops::Rem | |
operator- (унарный) | std::ops::Neg | |
operator! / operator~ | std::ops::Not | Rust использует ! и для логического, и для побитового НЕ (оператора ~ нет) |
operator&, |, ^ | BitAnd, BitOr, BitXor | |
operator<<, >> (сдвиг) | Shl, Shr | НЕ потоковый ввод-вывод! |
operator+= | std::ops::AddAssign | Принимает &mut self (а не self) |
operator[] | std::ops::Index / IndexMut | Возвращает &Output / &mut Output |
operator() | Fn / FnMut / FnOnce | Замыкания реализуют их; напрямую impl Fn написать нельзя |
operator== | PartialEq (+ Eq) | В std::cmp, а не в std::ops |
operator< | PartialOrd (+ Ord) | В std::cmp |
operator<< (поток) | fmt::Display | println!("{}", x) |
operator<< (отладка) | fmt::Debug | println!("{:?}", x) |
operator bool | Прямого аналога нет | Используйте impl From<T> for bool или именованный метод вроде .is_empty() |
operator T() (неявное преобразование) | Неявных преобразований нет | Используйте трейты From/Into (явные) |
Ограничители: что запрещает Rust
- Нет неявных преобразований:
operator int()в C++ может вызывать тихие и неожиданные приведения. В Rust нет операторов неявного преобразования — используйтеFrom/Intoи вызывайте.into()явно. - Нельзя перегружать
&&/||: C++ это позволяет (и ломает семантику короткого замыкания!). Rust — нет. - Нельзя перегружать
=: присваивание всегда является перемещением или копированием, никогда не определяется пользователем. Составное присваивание (+=) перегружается черезAddAssignи т. д. - Нельзя перегружать
,: C++ позволяетoperator,()— один из самых печально известных подводных камней C++. Rust — нет. - Нельзя перегружать
&(взятие адреса): ещё один подводный камень C++ (для обхода существуетstd::addressof). Оператор&в Rust всегда означает «заимствование». - Правила согласованности (coherence): можно реализовать
Add<Foreign>только для собственного типа илиAdd<YourType>для чужого типа — но никогдаAdd<Foreign>дляForeign. Это предотвращает конфликтующие определения операторов между крейтами.
Главное: в C++ перегрузка операторов мощна, но в основном не регламентирована — можно перегружать почти что угодно, включая запятую и взятие адреса, а неявные преобразования могут срабатывать незаметно. Rust даёт ту же выразительность для арифметических и операторов сравнения через трейты, но блокирует исторически опасные перегрузки и требует, чтобы все преобразования были явными.
Трейты в Rust (реализация для встроенных типов)
- Rust позволяет реализовать пользовательский трейт даже для встроенных типов, например u32, как в этом примере. Однако либо трейт, либо тип должен принадлежать крейту
trait IsSecret {
fn is_secret(&self);
}
// Трейт IsSecret принадлежит нашему крейту, поэтому всё в порядке
impl IsSecret for u32 {
fn is_secret(&self) {
if *self == 42 {
println!("Is secret of life");
}
}
}
fn main() {
42u32.is_secret();
43u32.is_secret();
}
Трейты в Rust (наследование интерфейсов)
- Трейты поддерживают наследование интерфейсов и реализации по умолчанию
trait Animal {
// Реализация по умолчанию
fn is_mammal(&self) -> bool {
true
}
}
trait Feline : Animal {
// Реализация по умолчанию
fn is_feline(&self) -> bool {
true
}
}
struct Cat;
// Используем реализации по умолчанию. Обратите внимание: все надтрейты нужно реализовать отдельно
impl Feline for Cat {}
impl Animal for Cat {}
fn main() {
let c = Cat{};
println!("{} {}", c.is_mammal(), c.is_feline());
}
Упражнение: реализация трейта Logger
🟡 Средний уровень
- Реализуйте трейт
Logс единственным методом log(), который принимает u64- Реализуйте два разных логгера
SimpleLoggerиComplexLogger, которые реализуют трейтLog. Один должен выводить “Simple logger” вместе сu64, а другой — “Complex logger” вместе сu64
- Реализуйте два разных логгера
Решение (нажмите, чтобы развернуть)
trait Log {
fn log(&self, value: u64);
}
struct SimpleLogger;
struct ComplexLogger;
impl Log for SimpleLogger {
fn log(&self, value: u64) {
println!("Simple logger: {value}");
}
}
impl Log for ComplexLogger {
fn log(&self, value: u64) {
println!("Complex logger: {value} (hex: 0x{value:x}, binary: {value:b})");
}
}
fn main() {
let simple = SimpleLogger;
let complex = ComplexLogger;
simple.log(42);
complex.log(42);
}
// Вывод:
// Simple logger: 42
// Complex logger: 42 (hex: 0x2a, binary: 101010)
Ассоциированные типы трейтов в Rust
#[derive(Debug)]
struct Small(u32);
#[derive(Debug)]
struct Big(u32);
trait Double {
type T;
fn double(&self) -> Self::T;
}
impl Double for Small {
type T = Big;
fn double(&self) -> Self::T {
Big(self.0 * 2)
}
}
fn main() {
let a = Small(42);
println!("{:?}", a.double());
}
Реализация трейтов через impl в Rust
implможно использовать вместе с трейтами, чтобы принимать любой тип, который реализует трейт
trait Pet {
fn speak(&self);
}
struct Dog {}
struct Cat {}
impl Pet for Dog {
fn speak(&self) {println!("Woof!")}
}
impl Pet for Cat {
fn speak(&self) {println!("Meow")}
}
fn pet_speak(p: &impl Pet) {
p.speak();
}
fn main() {
let c = Cat {};
let d = Dog {};
pet_speak(&c);
pet_speak(&d);
}
Реализация трейтов через impl: возвращаемые значения
implтакже можно использовать в возвращаемом значении
trait Pet {}
struct Dog;
struct Cat;
impl Pet for Cat {}
impl Pet for Dog {}
fn cat_as_pet() -> impl Pet {
let c = Cat {};
c
}
fn dog_as_pet() -> impl Pet {
let d = Dog {};
d
}
fn main() {
let p = cat_as_pet();
let d = dog_as_pet();
}
Динамические трейты в Rust
- Динамические трейты позволяют вызывать функциональность трейта, не зная конкретного типа. Это называется
стирание типа(type erasure)
trait Pet {
fn speak(&self);
}
struct Dog {}
struct Cat {x: u32}
impl Pet for Dog {
fn speak(&self) {println!("Woof!")}
}
impl Pet for Cat {
fn speak(&self) {println!("Meow")}
}
fn pet_speak(p: &dyn Pet) {
p.speak();
}
fn main() {
let c = Cat {x: 42};
let d = Dog {};
pet_speak(&c);
pet_speak(&d);
}
Выбор между impl Trait, dyn Trait и перечислениями
Все три подхода дают полиморфизм, но с разными компромиссами:
| Подход | Диспетчеризация | Производительность | Разнородные коллекции? | Когда использовать |
|---|---|---|---|---|
impl Trait / обобщения | Статическая (мономорфизация) | Без накладных расходов — встраивается на этапе компиляции | Нет — каждый слот имеет один конкретный тип | Выбор по умолчанию. Аргументы функций, возвращаемые типы |
dyn Trait | Динамическая (vtable) | Небольшие накладные расходы на вызов (~одно косвенное обращение через указатель) | Да — Vec<Box<dyn Trait>> | Когда нужны смешанные типы в коллекции или расширяемость в стиле плагинов |
enum | Сопоставление с образцом | Без накладных расходов — варианты известны на этапе компиляции | Да — но только известные варианты | Когда набор вариантов закрыт и известен на этапе компиляции |
#![allow(unused)]
fn main() {
trait Shape {
fn area(&self) -> f64;
}
struct Circle { radius: f64 }
struct Rect { w: f64, h: f64 }
impl Shape for Circle { fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius } }
impl Shape for Rect { fn area(&self) -> f64 { self.w * self.h } }
// Статическая диспетчеризация — компилятор генерирует отдельный код для каждого типа
fn print_area(s: &impl Shape) { println!("{}", s.area()); }
// Динамическая диспетчеризация — одна функция, работает с любым Shape за указателем
fn print_area_dyn(s: &dyn Shape) { println!("{}", s.area()); }
// Перечисление — закрытый набор, трейт не нужен
enum ShapeEnum { Circle(f64), Rect(f64, f64) }
impl ShapeEnum {
fn area(&self) -> f64 {
match self {
ShapeEnum::Circle(r) => std::f64::consts::PI * r * r,
ShapeEnum::Rect(w, h) => w * h,
}
}
}
}
Для программистов C++:
impl Traitпохож на шаблоны C++ (мономорфизация, без накладных расходов).dyn Traitпохож на виртуальные функции C++ (диспетчеризация через vtable). Перечисления Rust сmatchпохожи наstd::variantсstd::visit— но исчерпывающее сопоставление обеспечивает компилятор.
Эмпирическое правило: начинайте с
impl Trait(статическая диспетчеризация). Прибегайте кdyn Trait, только когда нужны разнородные коллекции или когда конкретный тип неизвестен на этапе компиляции. Используйтеenum, когда вы владеете всеми вариантами.
Обобщения в Rust
Что вы узнаете: обобщённые параметры типов, мономорфизацию (обобщения без накладных расходов), ограничения трейтами и то, как обобщения Rust соотносятся с шаблонами C++ — с более понятными сообщениями об ошибках и без SFINAE.
- Обобщения позволяют повторно использовать один и тот же алгоритм или структуру данных для разных типов данных
- Обобщённый параметр записывается как идентификатор в
<>, например:<T>. Параметр может иметь любое допустимое имя, но обычно его делают коротким - Компилятор выполняет мономорфизацию на этапе компиляции, то есть генерирует новый тип для каждого варианта
T, который встречается в коде
- Обобщённый параметр записывается как идентификатор в
// Возвращает кортеж типа <T>, составленный из left и right типа <T>
fn pick<T>(x: u32, left: T, right: T) -> (T, T) {
if x == 42 {
(left, right)
} else {
(right, left)
}
}
fn main() {
let a = pick(42, true, false);
let b = pick(42, "hello", "world");
println!("{a:?}, {b:?}");
}
Обобщения в Rust: специализация
- Обобщения можно применять и к типам данных, и к ассоциированным методам. Реализацию можно специализировать для конкретного
<T>(например,f32иu32)
#[derive(Debug)] // Мы обсудим это позже
struct Point<T> {
x : T,
y : T,
}
impl<T> Point<T> {
fn new(x: T, y: T) -> Self {
Point {x, y}
}
fn set_x(&mut self, x: T) {
self.x = x;
}
fn set_y(&mut self, y: T) {
self.y = y;
}
}
impl Point<f32> {
fn is_secret(&self) -> bool {
self.x == 42.0
}
}
fn main() {
let mut p = Point::new(2, 4); // i32
let q = Point::new(2.0, 4.0); // f32
p.set_x(42);
p.set_y(43);
println!("{p:?} {q:?} {}", q.is_secret());
}
Упражнение: обобщения
🟢 Начальный уровень
- Измените тип
Point, чтобы для x и y использовались два разных типа (TиU)
Решение (нажмите, чтобы развернуть)
#[derive(Debug)]
struct Point<T, U> {
x: T,
y: U,
}
impl<T, U> Point<T, U> {
fn new(x: T, y: U) -> Self {
Point { x, y }
}
}
fn main() {
let p1 = Point::new(42, 3.14); // Point<i32, f64>
let p2 = Point::new("hello", true); // Point<&str, bool>
let p3 = Point::new(1u8, 1000u64); // Point<u8, u64>
println!("{p1:?}");
println!("{p2:?}");
println!("{p3:?}");
}
// Вывод:
// Point { x: 42, y: 3.14 }
// Point { x: "hello", y: true }
// Point { x: 1, y: 1000 }
Комбинирование трейтов и обобщений в Rust
- Трейты можно использовать для наложения ограничений на обобщённые типы (constraints)
- Ограничение задаётся через
:после обобщённого параметра типа или черезwhere. Ниже определена обобщённая функцияget_area, которая принимает любой типT, если он реализует трейтComputeArea
#![allow(unused)]
fn main() {
trait ComputeArea {
fn area(&self) -> u64;
}
fn get_area<T: ComputeArea>(t: &T) -> u64 {
t.area()
}
}
Комбинирование трейтов и обобщений в Rust: несколько ограничений
- Можно задать несколько ограничений трейтами
trait Fish {}
trait Mammal {}
struct Shark;
struct Whale;
impl Fish for Shark {}
impl Fish for Whale {}
impl Mammal for Whale {}
fn only_fish_and_mammals<T: Fish + Mammal>(_t: &T) {}
fn main() {
let w = Whale {};
only_fish_and_mammals(&w);
let _s = Shark {};
// Не скомпилируется
only_fish_and_mammals(&_s);
}
Ограничения трейтов в типах данных
- Ограничения трейтами можно комбинировать с обобщениями в типах данных
- В следующем примере мы определяем трейт
PrintDescriptionи обобщённую структуруShapeс полем, ограниченным этим трейтом
#![allow(unused)]
fn main() {
trait PrintDescription {
fn print_description(&self);
}
struct Shape<S: PrintDescription> {
shape: S,
}
// Обобщённая реализация Shape для любого типа, который реализует PrintDescription
impl<S: PrintDescription> Shape<S> {
fn print(&self) {
self.shape.print_description();
}
}
}
Упражнение: ограничения трейтов и обобщения
🟡 Средний уровень
- Реализуйте структуру с обобщённым полем
cipher, которое реализуетCipherText
#![allow(unused)]
fn main() {
trait CipherText {
fn encrypt(&self);
}
// TO DO
//struct Cipher<>
}
- Затем реализуйте метод
encryptдля структурыimpl, который вызываетencryptуcipher
#![allow(unused)]
fn main() {
// TO DO
impl for Cipher<> {}
}
- Далее реализуйте
CipherTextдля двух структур,CipherOneиCipherTwo(достаточноprintln()). СоздайтеCipherOneиCipherTwoи используйтеCipherдля их вызова
Решение (нажмите, чтобы развернуть)
trait CipherText {
fn encrypt(&self);
}
struct Cipher<T: CipherText> {
cipher: T,
}
impl<T: CipherText> Cipher<T> {
fn encrypt(&self) {
self.cipher.encrypt();
}
}
struct CipherOne;
struct CipherTwo;
impl CipherText for CipherOne {
fn encrypt(&self) {
println!("CipherOne encryption applied");
}
}
impl CipherText for CipherTwo {
fn encrypt(&self) {
println!("CipherTwo encryption applied");
}
}
fn main() {
let c1 = Cipher { cipher: CipherOne };
let c2 = Cipher { cipher: CipherTwo };
c1.encrypt();
c2.encrypt();
}
// Вывод:
// CipherOne encryption applied
// CipherTwo encryption applied
Паттерн состояний (type state) и обобщения в Rust
-
Типы Rust можно использовать, чтобы обеспечить переходы конечного автомата на этапе компиляции
- Рассмотрим
Droneс двумя состояниями:IdleиFlying. В состоянииIdleединственный допустимый метод —takeoff(). В состоянииFlyingразрешёнland()
- Рассмотрим
-
Один из подходов — смоделировать конечный автомат примерно так
#![allow(unused)]
fn main() {
enum DroneState {
Idle,
Flying
}
struct Drone {x: u64, y: u64, z: u64, state: DroneState} // x, y, z — координаты
}
- Это требует множества проверок во время выполнения, чтобы обеспечить семантику конечного автомата — ▶ попробуйте, чтобы увидеть почему
Паттерн состояний на обобщениях
- Обобщения позволяют обеспечить конечный автомат на этапе компиляции. Для этого нужен специальный обобщённый тип
PhantomData<T> PhantomData<T>— это маркерный тип данныхнулевого размера. В данном случае мы используем его для представления состоянийIdleиFlying, но во время выполнения он занимаетнольбайт- Обратите внимание, что методы
takeoffиlandпринимаютselfкак параметр. Это называетсяпоглощением(в отличие от&self, которое использует заимствование). По сути, после вызоваtakeoff()дляDrone<Idle>мы можем получить толькоDrone<Flying>и наоборот
#![allow(unused)]
fn main() {
struct Drone<T> {x: u64, y: u64, z: u64, state: PhantomData<T> }
impl Drone<Idle> {
fn takeoff(self) -> Drone<Flying> {...}
}
impl Drone<Flying> {
fn land(self) -> Drone<Idle> { ...}
}
}
- [▶ Попробуйте в Rust Playground](https://play.rust-lang.org/)
Паттерн состояний: ключевые выводы
- Основные выводы:
- Состояния можно представить структурами (нулевого размера)
- Состояние
Tможно комбинировать сPhantomData<T>(нулевого размера) - Реализация методов для конкретного этапа конечного автомата — это просто
impl State<T> - Для перехода из одного состояния в другое используйте метод, который поглощает
self - Это даёт абстракции
без накладных расходов. Компилятор может обеспечить конечный автомат на этапе компиляции, и невозможно вызвать методы, если состояние неподходящее
Паттерн-строитель в Rust
- Поглощение
selfможет быть полезным для паттерна-строителя - Рассмотрим конфигурацию GPIO с несколькими десятками выводов. Каждый вывод может быть установлен в высокий или низкий уровень (по умолчанию — низкий)
#![allow(unused)]
fn main() {
#[derive(default)]
enum PinState {
#[default]
Low,
High,
}
#[derive(default)]
struct GPIOConfig {
pin0: PinState,
pin1: PinState
...
}
}
- Паттерн-строитель можно использовать, чтобы собрать конфигурацию GPIO через цепочку вызовов — ▶ Попробуйте
Трейты From и Into в Rust
Что вы узнаете: трейты преобразования типов в Rust —
From<T>иInto<T>для преобразований, которые не могут завершиться ошибкой, иTryFromиTryIntoдля преобразований, которые могут. РеализуйтеFromи получитеIntoбесплатно. Заменяют операторы преобразования и конструкторы C++.
FromиInto— взаимодополняющие трейты для преобразования типов- Обычно типы реализуют трейт
From.String::from("Rust")преобразует&strвString. Когда существуетFrom<T> for U, Rust также предоставляетInto<U> for T, поэтомуlet s: String = "Rust".into();тоже работает.
struct Point {x: u32, y: u32}
// Создаём Point из кортежа
impl From<(u32, u32)> for Point {
fn from(xy : (u32, u32)) -> Self {
Point {x : xy.0, y: xy.1} // Создаём Point из элементов кортежа
}
}
fn main() {
let s = String::from("Rust");
let x = u32::from(true);
let p = Point::from((40, 42));
// let p : Point = (40,42).into(); // Альтернативная запись выражения выше
println!("s: {s} x:{x} p.x:{} p.y {}", p.x, p.y);
}
Упражнение: From и Into
- Реализуйте трейт
FromдляPointс преобразованием в типTransposePoint.TransposePointменяет местами элементыxиyуPoint
Решение (нажмите, чтобы развернуть)
struct Point { x: u32, y: u32 }
struct TransposePoint { x: u32, y: u32 }
impl From<Point> for TransposePoint {
fn from(p: Point) -> Self {
TransposePoint { x: p.y, y: p.x }
}
}
fn main() {
let p = Point { x: 10, y: 20 };
let tp = TransposePoint::from(p);
println!("Transposed: x={}, y={}", tp.x, tp.y); // x=20, y=10
// Через .into() — работает автоматически, когда реализован From
let p2 = Point { x: 3, y: 7 };
let tp2: TransposePoint = p2.into();
println!("Transposed: x={}, y={}", tp2.x, tp2.y); // x=7, y=3
}
// Вывод:
// Transposed: x=20, y=10
// Transposed: x=7, y=3
Трейт Default в Rust
Defaultможно использовать, чтобы задать значения по умолчанию для типа- Типы могут использовать макрос
DeriveсDefaultили предоставить собственную реализацию
- Типы могут использовать макрос
#[derive(Default, Debug)]
struct Point {x: u32, y: u32}
#[derive(Debug)]
struct CustomPoint {x: u32, y: u32}
impl Default for CustomPoint {
fn default() -> Self {
CustomPoint {x: 42, y: 42}
}
}
fn main() {
let x = Point::default(); // Создаёт Point{0, 0}
println!("{x:?}");
let y = CustomPoint::default();
println!("{y:?}");
}
Применение трейта Default
- Трейт
Defaultприменяется в нескольких случаях, в том числе- Частичное копирование с инициализацией остальных полей значениями по умолчанию
- Значение по умолчанию для типов
Optionв методах вродеunwrap_or_default()
#[derive(Debug)]
struct CustomPoint {x: u32, y: u32}
impl Default for CustomPoint {
fn default() -> Self {
CustomPoint {x: 42, y: 42}
}
}
fn main() {
let x = CustomPoint::default();
// Переопределяем y, а остальные поля оставляем по умолчанию
let y = CustomPoint {y: 43, ..CustomPoint::default()};
println!("{x:?} {y:?}");
let z : Option<CustomPoint> = None;
// Попробуйте заменить unwrap_or_default() на unwrap()
println!("{:?}", z.unwrap_or_default());
}
Другие преобразования типов в Rust
- Rust не поддерживает неявных преобразований типов, а для
явныхпреобразований используетсяas asследует использовать с осторожностью, потому что он может приводить к потере данных при сужении и т. п. В общем случае предпочтительнее использоватьinto()илиfrom(), где это возможно
fn main() {
let f = 42u8;
// let g : u32 = f; // Не скомпилируется
let g = f as u32; // Работает, но не рекомендуется. Подчиняется правилам сужения
let g : u32 = f.into(); // Предпочтительная форма; не может завершиться ошибкой, проверяется компилятором
// let k : u8 = g.into(); // Не скомпилируется; сужение может привести к потере данных
// Для операции сужения нужно использовать try_into
if let Ok(k) = TryInto::<u8>::try_into(g) {
println!("{k}");
}
}
12. Замыкания
Замыкания в Rust
Что вы узнаете: замыкания как анонимные функции, три трейта захвата (
Fn,FnMut,FnOnce), замыканияmoveи то, как замыкания Rust соотносятся с лямбдами C++ — с автоматическим анализом захвата вместо ручных спецификаций[&]/[=].
- Замыкания — это анонимные функции, которые могут захватывать своё окружение
- Аналог в C++: лямбды (
[&](int x) { return x + 1; }) - Ключевое отличие: у замыканий Rust есть три трейта захвата (
Fn,FnMut,FnOnce), которые компилятор выбирает автоматически - Режимы захвата в C++ (
[=],[&],[this]) задаются вручную и подвержены ошибкам (висячий[&]!) - Проверка заимствований Rust предотвращает висячие захваты на этапе компиляции
- Аналог в C++: лямбды (
- Замыкания обозначаются символом
||. Параметры размещаются внутри||и могут использовать вывод типов - Замыкания часто используются вместе с итераторами (следующая тема)
fn add_one(x: u32) -> u32 {
x + 1
}
fn main() {
let add_one_v1 = |x : u32| {x + 1}; // Тип указан явно
let add_one_v2 = |x| {x + 1}; // Тип выводится из места вызова
let add_one_v3 = |x| x+1; // Допустимо для однострочных функций
println!("{} {} {} {}", add_one(42), add_one_v1(42), add_one_v2(42), add_one_v3(42) );
}
Упражнение: замыкания и захват переменных
🟡 Средний уровень
- Создайте замыкание, которое захватывает
Stringиз внешней области видимости и дописывает к ней текст (подсказка: используйтеmove) - Создайте вектор замыканий
Vec<Box<dyn Fn(i32) -> i32>>, содержащий замыкания, которые прибавляют 1, умножают на 2 и возводят входное значение в квадрат. Переберите вектор и примените каждое замыкание к числу 5
Решение (нажмите, чтобы развернуть)
fn main() {
// Часть 1: замыкание, которое захватывает String и дописывает к ней
let mut greeting = String::from("Hello");
let mut append = |suffix: &str| {
greeting.push_str(suffix);
};
append(", world");
append("!");
println!("{greeting}"); // "Hello, world!"
// Часть 2: вектор замыканий
let operations: Vec<Box<dyn Fn(i32) -> i32>> = vec![
Box::new(|x| x + 1), // прибавить 1
Box::new(|x| x * 2), // умножить на 2
Box::new(|x| x * x), // возвести в квадрат
];
let input = 5;
for (i, op) in operations.iter().enumerate() {
println!("Operation {i} on {input}: {}", op(input));
}
}
// Вывод:
// Hello, world!
// Operation 0 on 5: 6
// Operation 1 on 5: 10
// Operation 2 on 5: 25
Итераторы в Rust
- Итераторы — одна из самых мощных возможностей Rust. Они позволяют очень изящно выполнять операции над коллекциями, включая фильтрацию (
filter()), преобразование (map()), фильтрацию с преобразованием (filter_map()), поиск (find()) и многое другое - В примере ниже
|&x| *x >= 42— замыкание, которое выполняет то же сравнение.|x| println!("{x}")— ещё одно замыкание
fn main() {
let a = [0, 1, 2, 3, 42, 43];
for x in &a {
if *x >= 42 {
println!("{x}");
}
}
// То же, что и выше
a.iter().filter(|&x| *x >= 42).for_each(|x| println!("{x}"))
}
Итераторы в Rust: ленивость
- Ключевая особенность итераторов в том, что большинство из них
ленивые, то есть ничего не делают, пока их не вычислят. Например,a.iter().filter(|&x| *x >= 42);безfor_eachне сделало бы ничего. Компилятор Rust выдаёт явное предупреждение, когда замечает такую ситуацию
fn main() {
let a = [0, 1, 2, 3, 42, 43];
// Прибавляем единицу к каждому элементу и выводим его
let _ = a.iter().map(|x|x + 1).for_each(|x|println!("{x}"));
let found = a.iter().find(|&x|*x == 42);
println!("{found:?}");
// Считаем элементы
let count = a.iter().count();
println!("{count}");
}
Итераторы в Rust: сбор результатов
- Метод
collect()позволяет собрать результаты в отдельную коллекцию- В примере ниже
_вVec<_>— это подстановочный символ для типа, который возвращаетmap. Например, изmapможно даже вернутьString
- В примере ниже
fn main() {
let a = [0, 1, 2, 3, 42, 43];
let squared_a : Vec<_> = a.iter().map(|x|x*x).collect();
for x in &squared_a {
println!("{x}");
}
let squared_a_strings : Vec<_> = a.iter().map(|x|(x*x).to_string()).collect();
// Это уже строковые представления
for x in &squared_a_strings {
println!("{x}");
}
}
Упражнение: итераторы в Rust
🟢 Начальный уровень
- Создайте целочисленный массив из нечётных и чётных элементов. Переберите массив и разделите его на два разных вектора: в одном — чётные элементы, в другом — нечётные
- Можно ли сделать это за один проход (подсказка: используйте
partition())?
Решение (нажмите, чтобы развернуть)
fn main() {
let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// Подход 1: ручная итерация
let mut evens = Vec::new();
let mut odds = Vec::new();
for n in numbers {
if n % 2 == 0 {
evens.push(n);
} else {
odds.push(n);
}
}
println!("Evens: {evens:?}");
println!("Odds: {odds:?}");
// Подход 2: один проход с partition()
let (evens, odds): (Vec<i32>, Vec<i32>) = numbers
.into_iter()
.partition(|n| *n % 2 == 0);
println!("Evens (partition): {evens:?}");
println!("Odds (partition): {odds:?}");
}
// Вывод:
// Evens: [2, 4, 6, 8, 10]
// Odds: [1, 3, 5, 7, 9]
// Evens (partition): [2, 4, 6, 8, 10]
// Odds (partition): [1, 3, 5, 7, 9]
Промышленные шаблоны: реальные цепочки итераторов (
.map().collect(),.filter().collect(),.find_map()) из продакшн-кода на Rust см. в разделе Схлопывание пирамид присваиваний с помощью замыканий.
Инструменты для работы с итераторами: методы, заменяющие циклы C++
Следующие адаптеры итераторов широко используются в продакшн-коде на Rust. В C++ есть <algorithm> и диапазоны C++20, но цепочки итераторов Rust более компонуемы и используются чаще.
enumerate — индекс и значение (заменяет for (int i = 0; ...))
#![allow(unused)]
fn main() {
let sensors = vec!["temp0", "temp1", "temp2"];
for (idx, name) in sensors.iter().enumerate() {
println!("Sensor {idx}: {name}");
}
// Sensor 0: temp0
// Sensor 1: temp1
// Sensor 2: temp2
}
Аналог в C++: for (size_t i = 0; i < sensors.size(); ++i) { auto& name = sensors[i]; ... }
zip — попарное объединение элементов двух итераторов (заменяет параллельные циклы по индексу)
#![allow(unused)]
fn main() {
let names = ["gpu0", "gpu1", "gpu2"];
let temps = [72.5, 68.0, 75.3];
let report: Vec<String> = names.iter()
.zip(temps.iter())
.map(|(name, temp)| format!("{name}: {temp}°C"))
.collect();
println!("{report:?}");
// ["gpu0: 72.5°C", "gpu1: 68.0°C", "gpu2: 75.3°C"]
// Останавливается на более коротком итераторе — риска выхода за границы нет
}
Аналог в C++: for (size_t i = 0; i < std::min(names.size(), temps.size()); ++i) { ... }
flat_map — map и flatten для вложенных коллекций
#![allow(unused)]
fn main() {
// У каждого GPU несколько BDF на PCIe; собираем все BDF со всех GPU
let gpu_bdfs = vec![
vec!["0000:01:00.0", "0000:02:00.0"],
vec!["0000:41:00.0"],
vec!["0000:81:00.0", "0000:82:00.0"],
];
let all_bdfs: Vec<&str> = gpu_bdfs.iter()
.flat_map(|bdfs| bdfs.iter().copied())
.collect();
println!("{all_bdfs:?}");
// ["0000:01:00.0", "0000:02:00.0", "0000:41:00.0", "0000:81:00.0", "0000:82:00.0"]
}
Аналог в C++: вложенный цикл for, который добавляет элементы в один вектор.
chain — конкатенация двух итераторов
#![allow(unused)]
fn main() {
let critical_gpus = vec!["gpu0", "gpu3"];
let warning_gpus = vec!["gpu1", "gpu5"];
// Обрабатываем все отмеченные GPU, сначала критичные
for gpu in critical_gpus.iter().chain(warning_gpus.iter()) {
println!("Flagged: {gpu}");
}
}
windows и chunks — скользящие и фиксированные окна над срезами
#![allow(unused)]
fn main() {
let temps = [70, 72, 75, 73, 71, 68, 65];
// windows(3): скользящее окно размера 3 — выявляем тренды
let rising = temps.windows(3)
.any(|w| w[0] < w[1] && w[1] < w[2]);
println!("Rising trend detected: {rising}"); // true (70 < 72 < 75)
// chunks(2): группы фиксированного размера — обрабатываем парами
for pair in temps.chunks(2) {
println!("Pair: {pair:?}");
}
// Pair: [70, 72]
// Pair: [75, 73]
// Pair: [71, 68]
// Pair: [65] ← последний фрагмент может быть меньше
}
Аналог в C++: ручная арифметика индексов с i и i+1/i+2.
fold — накопление в одно значение (заменяет std::accumulate)
#![allow(unused)]
fn main() {
let errors = vec![
("gpu0", 3u32),
("gpu1", 0),
("gpu2", 7),
("gpu3", 1),
];
// Считаем общее число ошибок и формируем сводку за один проход
let (total, summary) = errors.iter().fold(
(0u32, String::new()),
|(count, mut s), (name, errs)| {
if *errs > 0 {
s.push_str(&format!("{name}:{errs} "));
}
(count + errs, s)
},
);
println!("Total errors: {total}, details: {summary}");
// Total errors: 11, details: gpu0:3 gpu2:7 gpu3:1
}
scan — преобразование с состоянием (накопленная сумма, отслеживание разностей)
#![allow(unused)]
fn main() {
let readings = [100, 105, 103, 110, 108];
// Вычисляем разности между соседними показаниями
let deltas: Vec<i32> = readings.iter()
.scan(None::<i32>, |prev, &val| {
let delta = prev.map(|p| val - p);
*prev = Some(val);
Some(delta)
})
.flatten() // Убираем начальный None
.collect();
println!("Deltas: {deltas:?}"); // [5, -2, 7, -2]
}
Краткий справочник: цикл C++ → итератор Rust
| Шаблон C++ | Итератор Rust | Пример |
|---|---|---|
for (int i = 0; i < v.size(); i++) | .enumerate() | v.iter().enumerate() |
| Параллельный перебор с индексом | .zip() | a.iter().zip(b.iter()) |
| Вложенный цикл → плоский результат | .flat_map() | vecs.iter().flat_map(|v| v.iter()) |
| Конкатенация двух контейнеров | .chain() | a.iter().chain(b.iter()) |
Скользящее окно v[i..i+n] | .windows(n) | v.windows(3) |
| Обработка группами фиксированного размера | .chunks(n) | v.chunks(4) |
std::accumulate / ручной аккумулятор | .fold() | .fold(init, |acc, x| ...) |
| Накопленная сумма / отслеживание разностей | .scan() | .scan(state, |s, x| ...) |
while (it != end && count < n) { ++it; ++count; } | .take(n) | .iter().take(5) |
while (it != end && !pred(*it)) { ++it; } | .skip_while() | .skip_while(|x| x < &threshold) |
std::any_of | .any() | .iter().any(|x| x > &limit) |
std::all_of | .all() | .iter().all(|x| x.is_valid()) |
std::none_of | !.any() | !iter.any(|x| x.failed()) |
std::count_if | .filter().count() | .filter(|x| x > &0).count() |
std::min_element / std::max_element | .min() / .max() | .iter().max() → Option<&T> |
std::unique | .dedup() (на отсортированном) | v.dedup() (на месте в Vec) |
Упражнение: цепочки итераторов
Дано: данные датчиков в виде Vec<(String, f64)> (имя, температура). Напишите одну
цепочку итераторов, которая:
- Отфильтровывает датчики с температурой > 80.0
- Сортирует их по температуре (по убыванию)
- Форматирует каждый как
"{name}: {temp}°C [ALARM]" - Собирает результат в
Vec<String>
Подсказка: нужно вызвать .collect() перед .sort_by(), поскольку для сортировки нужен Vec.
Решение (нажмите, чтобы развернуть)
fn alarm_report(sensors: &[(String, f64)]) -> Vec<String> {
let mut hot: Vec<_> = sensors.iter()
.filter(|(_, temp)| *temp > 80.0)
.collect();
hot.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
hot.iter()
.map(|(name, temp)| format!("{name}: {temp}°C [ALARM]"))
.collect()
}
fn main() {
let sensors = vec![
("gpu0".to_string(), 72.5),
("gpu1".to_string(), 85.3),
("gpu2".to_string(), 91.0),
("gpu3".to_string(), 78.0),
("gpu4".to_string(), 88.7),
];
for line in alarm_report(&sensors) {
println!("{line}");
}
}
// Вывод:
// gpu2: 91°C [ALARM]
// gpu4: 88.7°C [ALARM]
// gpu1: 85.3°C [ALARM]
Итераторы в Rust: собственные типы
- Трейт
Iteratorиспользуется для реализации итерации по пользовательским типам (https://doc.rust-lang.org/std/iter/trait.IntoIterator.html)- В примере мы реализуем итератор для последовательности Фибоначчи, которая начинается с 1, 1, 2, …, а каждый следующий элемент — это сумма двух предыдущих
- Ассоциированный тип в
Iterator(type Item = u32;) задаёт тип значений, которые выдаёт наш итератор (u32) - Метод
next()содержит саму логику итератора. В данном случае всё состояние хранится в структуреFibonacci - Можно было бы реализовать ещё один трейт,
IntoIterator, с методомinto_iter()для более специализированных итераторов - ▶ Попробуйте в Rust Playground
Инструменты для работы с итераторами
Справочник по инструментам для работы с итераторами
Что вы узнаете: продвинутые комбинаторы итераторов помимо
filter/map/collect—enumerate,zip,chain,flat_map,scan,windowsиchunks. Они необходимы, чтобы заменить индексные циклыforв стиле C безопасными и выразительными итераторами Rust.
Базовой цепочки filter/map/collect хватает для многих случаев, но библиотека итераторов Rust
гораздо богаче. Этот раздел описывает инструменты, которыми вы будете пользоваться каждый день — особенно при
переводе циклов C, которые вручную отслеживают индексы, накапливают результаты или обрабатывают
данные фиксированными порциями.
Краткая таблица
| Метод | Аналог в C | Что делает | Возвращает |
|---|---|---|---|
enumerate() | for (int i=0; ...) | Сопоставляет каждый элемент с его индексом | (usize, T) |
zip(other) | Параллельные массивы с одним индексом | Попарно объединяет элементы двух итераторов | (A, B) |
chain(other) | Обработать массив1, затем массив2 | Конкатенирует два итератора | T |
flat_map(f) | Вложенные циклы | Отображает и выравнивает на один уровень | U |
windows(n) | for (int i=0; i<len-n+1; i++) &arr[i..i+n] | Перекрывающиеся срезы размера n | &[T] |
chunks(n) | Обрабатывать по n элементов за раз | Непересекающиеся срезы размера n | &[T] |
fold(init, f) | int acc = init; for (...) acc = f(acc, x); | Свести к одному значению | Acc |
scan(init, f) | Накапливающий аккумулятор с выводом | Как fold, но выдаёт промежуточные результаты | Option<B> |
take(n) / skip(n) | Начать цикл со смещения / ограничить | Первые n / пропустить первые n элементов | T |
take_while(f) / skip_while(f) | while (pred) {...} | Брать/пропускать, пока предикат истинен | T |
peekable() | Заглядывание вперёд через arr[i+1] | Позволяет .peek() без потребления элемента | T |
step_by(n) | for (i=0; i<len; i+=n) | Брать каждый n-й элемент | T |
unzip() | Разбиение параллельных массивов | Собрать пары в две коллекции | (A, B) |
sum() / product() | Накопление суммы/произведения | Свести через + или * | T |
min() / max() | Поиск экстремумов | Возвращают Option<T> | Option<T> |
any(f) / all(f) | bool found = false; for (...) ... | Булев поиск с коротким замыканием | bool |
position(f) | for (i=0; ...) if (pred) return i; | Индекс первого совпадения | Option<usize> |
enumerate — индекс и значение (заменяет индексные циклы C)
fn main() {
let sensors = ["GPU_TEMP", "CPU_TEMP", "FAN_RPM", "PSU_WATT"];
// В стиле C: for (int i = 0; i < 4; i++) printf("[%d] %s\n", i, sensors[i]);
for (i, name) in sensors.iter().enumerate() {
println!("[{i}] {name}");
}
// Найти индекс конкретного датчика
let gpu_idx = sensors.iter().position(|&s| s == "GPU_TEMP");
println!("GPU sensor at index: {gpu_idx:?}"); // Some(0)
}
zip — параллельный перебор (заменяет циклы по параллельным массивам)
fn main() {
let names = ["accel_diag", "nic_diag", "cpu_diag"];
let statuses = [true, false, true];
let durations_ms = [1200, 850, 3400];
// C: for (int i=0; i<3; i++) printf("%s: %s (%d ms)\n", names[i], ...);
for ((name, passed), ms) in names.iter().zip(&statuses).zip(&durations_ms) {
let status = if *passed { "PASS" } else { "FAIL" };
println!("{name}: {status} ({ms} ms)");
}
}
chain — конкатенация итераторов
fn main() {
let critical = vec!["ECC error", "Thermal shutdown"];
let warnings = vec!["Link degraded", "Fan slow"];
// Обрабатываем все события в порядке приоритета
let all_events: Vec<_> = critical.iter().chain(warnings.iter()).collect();
println!("{all_events:?}");
// ["ECC error", "Thermal shutdown", "Link degraded", "Fan slow"]
}
flat_map — выравнивание вложенных результатов
fn main() {
let lines = vec!["gpu:42:ok", "nic:99:fail", "cpu:7:ok"];
// Извлекаем все числовые значения из строк, разделённых двоеточиями
let numbers: Vec<u32> = lines.iter()
.flat_map(|line| line.split(':'))
.filter_map(|token| token.parse::<u32>().ok())
.collect();
println!("{numbers:?}"); // [42, 99, 7]
}
windows и chunks — скользящие и фиксированные группы
fn main() {
let temps = [65, 68, 72, 71, 75, 80, 78, 76];
// windows(3): перекрывающиеся группы по 3 (как скользящее среднее)
// C: for (int i = 0; i <= len-3; i++) avg(arr[i], arr[i+1], arr[i+2]);
let moving_avg: Vec<f64> = temps.windows(3)
.map(|w| w.iter().sum::<i32>() as f64 / 3.0)
.collect();
println!("Moving avg: {moving_avg:.1?}");
// chunks(2): непересекающиеся группы по 2
// C: for (int i = 0; i < len; i += 2) process(arr[i], arr[i+1]);
for pair in temps.chunks(2) {
println!("Chunk: {pair:?}");
}
// chunks_exact(2): то же самое, но возникает panic при наличии остатка
// Также: .remainder() возвращает оставшиеся элементы
}
fold и scan — накопление
fn main() {
let values = [10, 20, 30, 40, 50];
// fold: единственный итоговый результат (как накопительный цикл в C)
let sum = values.iter().fold(0, |acc, &x| acc + x);
println!("Sum: {sum}"); // 150
// Собираем строку через fold
let csv = values.iter()
.fold(String::new(), |acc, x| {
if acc.is_empty() { format!("{x}") }
else { format!("{acc},{x}") }
});
println!("CSV: {csv}"); // "10,20,30,40,50"
// scan: как fold, но выдаёт промежуточные результаты
let running_sum: Vec<i32> = values.iter()
.scan(0, |state, &x| {
*state += x;
Some(*state)
})
.collect();
println!("Running sum: {running_sum:?}"); // [10, 30, 60, 100, 150]
}
Упражнение: конвейер данных датчиков
Даны сырые показания датчиков (по одному на строку, формат "sensor_name:value:unit"). Напишите
конвейер итераторов, который:
- Разбирает каждую строку в
(name, f64, unit) - Отбрасывает показания ниже порога
- Группирует по имени датчика с помощью
foldвHashMap - Выводит среднее показание для каждого датчика
// Стартовый код
fn main() {
let raw_data = vec![
"gpu_temp:72.5:C",
"cpu_temp:65.0:C",
"gpu_temp:74.2:C",
"fan_rpm:1200.0:RPM",
"cpu_temp:63.8:C",
"gpu_temp:80.1:C",
"fan_rpm:1150.0:RPM",
];
let threshold = 70.0;
// TODO: Разобрать, отфильтровать значения >= threshold, сгруппировать по имени, посчитать средние
}
Решение (нажмите, чтобы развернуть)
use std::collections::HashMap;
fn main() {
let raw_data = vec![
"gpu_temp:72.5:C",
"cpu_temp:65.0:C",
"gpu_temp:74.2:C",
"fan_rpm:1200.0:RPM",
"cpu_temp:63.8:C",
"gpu_temp:80.1:C",
"fan_rpm:1150.0:RPM",
];
let threshold = 70.0;
// Разбор → фильтрация → группировка → среднее
let grouped = raw_data.iter()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(3, ':').collect();
if parts.len() == 3 {
let value: f64 = parts[1].parse().ok()?;
Some((parts[0], value, parts[2]))
} else {
None
}
})
.filter(|(_, value, _)| *value >= threshold)
.fold(HashMap::<&str, Vec<f64>>::new(), |mut acc, (name, value, _)| {
acc.entry(name).or_default().push(value);
acc
});
for (name, values) in &grouped {
let avg = values.iter().sum::<f64>() / values.len() as f64;
println!("{name}: avg={avg:.1} ({} readings)", values.len());
}
}
// Вывод (порядок может отличаться):
// gpu_temp: avg=75.6 (3 readings)
// fan_rpm: avg=1175.0 (2 readings)
Итераторы в Rust: собственные типы
- Трейт
Iteratorиспользуется для реализации итерации по пользовательским типам (https://doc.rust-lang.org/std/iter/trait.IntoIterator.html)- В примере мы реализуем итератор для последовательности Фибоначчи, которая начинается с 1, 1, 2, …, а каждый следующий элемент — это сумма двух предыдущих
- Ассоциированный тип в
Iterator(type Item = u32;) задаёт тип значений, которые выдаёт наш итератор (u32) - Метод
next()содержит саму логику итератора. В данном случае всё состояние хранится в структуреFibonacci - Можно было бы реализовать ещё один трейт,
IntoIterator, с методомinto_iter()для более специализированных итераторов - https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=ab367dc2611e1b5a0bf98f1185b38f3f
Конкурентность в Rust
Что вы узнаете: модель конкурентности Rust — потоки, маркерные трейты
Send/Sync,Mutex<T>,Arc<T>, каналы и то, как компилятор предотвращает гонки данных на этапе компиляции. Никаких накладных расходов на потокобезопасность, которой вы не пользуетесь.
- В Rust есть встроенная поддержка конкурентности, похожая на
std::threadв C++- Ключевое отличие: Rust предотвращает гонки данных на этапе компиляции через маркерные трейты
SendиSync - В C++ передача
std::vectorмежду потоками без мьютекса — это неопределённое поведение, но код компилируется без проблем. В Rust такой код не скомпилируется. Mutex<T>в Rust оборачивает данные, а не только доступ к ним: прочитать данные, не взяв блокировку, физически невозможно
- Ключевое отличие: Rust предотвращает гонки данных на этапе компиляции через маркерные трейты
thread::spawn()создаёт отдельный поток, который параллельно выполняет замыкание||
use std::thread;
use std::time::Duration;
fn main() {
let handle = 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));
}
handle.join().unwrap(); // handle.join() гарантирует, что порождённый поток завершится
}
Конкурентность в Rust: области видимости потоков
thread::scope()можно использовать, когда нужно заимствовать данные из окружения. Это работает, потому чтоthread::scopeждёт завершения внутренних потоков- Попробуйте выполнить это упражнение без
thread::scope, чтобы увидеть проблему
use std::thread;
fn main() {
let a = [0, 1, 2];
thread::scope(|scope| {
scope.spawn(|| {
for x in &a {
println!("{x}");
}
});
});
}
Конкурентность в Rust: перемещение владения в поток
- Можно также использовать
move, чтобы передать владение потоку. Для типовCopy, таких как[i32; 3], ключевое словоmoveкопирует данные в замыкание, и исходная переменная остаётся пригодной для использования
use std::thread;
fn main() {
let mut a = [0, 1, 2];
let handle = thread::spawn(move || {
for x in a {
println!("{x}");
}
});
a[0] = 42; // Не влияет на копию, переданную в поток
handle.join().unwrap();
}
Конкурентность в Rust: Arc
Arc<T>можно использовать, чтобы разделить доступные только для чтения ссылки между несколькими потокамиArcрасшифровывается как Atomic Reference Counted (атомарный подсчёт ссылок). Ссылка не освобождается, пока счётчик не достигнет 0Arc::clone()просто увеличивает счётчик ссылок, не копируя данные
use std::sync::Arc;
use std::thread;
fn main() {
let a = Arc::new([0, 1, 2]);
let mut handles = Vec::new();
for i in 0..2 {
let arc = Arc::clone(&a);
handles.push(thread::spawn(move || {
println!("Thread: {i} {arc:?}");
}));
}
handles.into_iter().for_each(|h| h.join().unwrap());
}
Конкурентность в Rust: Mutex
Arc<T>можно комбинировать сMutex<T>, чтобы получить изменяемые ссылкиMutexохраняет защищаемые данные и гарантирует, что доступ имеет только поток, который держит блокировку.MutexGuardавтоматически освобождается, когда выходит из области видимости (RAII). Примечание:std::mem::forgetвсё же может «утечь» защитник — поэтому точнее сказать «невозможно забыть разблокировать», а не «невозможно допустить утечку».
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..5 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
// MutexGuard уничтожается здесь — блокировка снимается автоматически
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("Final count: {}", *counter.lock().unwrap());
// Вывод: Final count: 5
}
Конкурентность в Rust: RwLock
RwLock<T>допускает несколько одновременных читателей или одного эксклюзивного писателя — шаблон блокировки для чтения/записи из C++ (std::shared_mutex)- Используйте
RwLock, когда чтений намного больше, чем записей (например, конфигурация, кэши) - Используйте
Mutex, когда частота чтения и записи примерно одинакова или критические секции короткие
- Используйте
use std::sync::{Arc, RwLock};
use std::thread;
fn main() {
let config = Arc::new(RwLock::new(String::from("v1.0")));
let mut handles = Vec::new();
// Запускаем 5 читателей — все могут работать одновременно
for i in 0..5 {
let config = Arc::clone(&config);
handles.push(thread::spawn(move || {
let val = config.read().unwrap(); // Несколько читателей — это нормально
println!("Reader {i}: {val}");
}));
}
// Один писатель — блокируется, пока все читатели не закончат
{
let config = Arc::clone(&config);
handles.push(thread::spawn(move || {
let mut val = config.write().unwrap(); // Эксклюзивный доступ
*val = String::from("v2.0");
println!("Writer: updated to {val}");
}));
}
for handle in handles {
handle.join().unwrap();
}
}
Конкурентность в Rust: отравление Mutex
- Если поток падает (panic), удерживая
MutexилиRwLock, блокировка становится отравленной (poisoned)- Последующие вызовы
.lock()возвращаютErr(PoisonError)— данные могут находиться в несогласованном состоянии - Можно восстановиться с помощью
.into_inner(), если вы уверены, что данные по-прежнему корректны - В C++ аналога нет —
std::mutexне знает понятия отравления; упавший поток просто оставляет блокировку захваченной
- Последующие вызовы
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let data = Arc::new(Mutex::new(vec![1, 2, 3]));
let data2 = Arc::clone(&data);
let handle = thread::spawn(move || {
let mut guard = data2.lock().unwrap();
guard.push(4);
panic!("oops!"); // Блокировка теперь отравлена
});
let _ = handle.join(); // Поток упал с panic
// Последующие попытки захватить блокировку возвращают Err(PoisonError)
match data.lock() {
Ok(guard) => println!("Data: {guard:?}"),
Err(poisoned) => {
println!("Lock was poisoned! Recovering...");
let guard = poisoned.into_inner(); // Всё равно получаем доступ к данным
println!("Recovered data: {guard:?}"); // [1, 2, 3, 4] — push выполнился до panic
}
}
}
Конкурентность в Rust: атомарные типы
- Для простых счётчиков и флагов типы
std::sync::atomicпозволяют избежать накладных расходовMutexAtomicBool,AtomicI32,AtomicU64,AtomicUsizeи т. д.- Аналог
std::atomic<T>из C++ — та же модель порядка доступа к памяти (Relaxed,Acquire,Release,SeqCst)
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;
fn main() {
let counter = Arc::new(AtomicU64::new(0));
let mut handles = Vec::new();
for _ in 0..10 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
counter.fetch_add(1, Ordering::Relaxed);
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("Counter: {}", counter.load(Ordering::SeqCst));
// Вывод: Counter: 10000
}
| Примитив | Когда использовать | Аналог в C++ |
|---|---|---|
Mutex<T> | Общее изменяемое состояние общего назначения | std::mutex + ручная привязка данных |
RwLock<T> | Нагрузка с преобладанием чтения | std::shared_mutex |
Atomic* | Простые счётчики, флаги, lock-free-шаблоны | std::atomic<T> |
Condvar | Ожидание, пока условие не станет истинным | std::condition_variable |
Конкурентность в Rust: Condvar
Condvar(условная переменная) позволяет потоку заснуть до тех пор, пока другой поток не сигнализирует, что условие изменилось- Всегда используется вместе с
Mutex— шаблон такой: захватить блокировку, проверить условие, ждать, если оно не выполнено, действовать, когда оно выполнено - Эквивалент
std::condition_variable/std::condition_variable::waitв C++ - Учитывает ложные пробуждения (spurious wakeups) — всегда перепроверяйте условие в цикле (или используйте
wait_while/wait_until)
- Всегда используется вместе с
use std::sync::{Arc, Condvar, Mutex};
use std::thread;
fn main() {
let pair = Arc::new((Mutex::new(false), Condvar::new()));
// Порождаем рабочий поток, который ждёт сигнала
let pair2 = Arc::clone(&pair);
let worker = thread::spawn(move || {
let (lock, cvar) = &*pair2;
let mut ready = lock.lock().unwrap();
// wait: засыпает до сигнала (всегда перепроверяйте в цикле из-за ложных пробуждений)
while !*ready {
ready = cvar.wait(ready).unwrap();
}
println!("Worker: condition met, proceeding!");
});
// Главный поток что-то делает, затем сигнализирует рабочему потоку
thread::sleep(std::time::Duration::from_millis(100));
{
let (lock, cvar) = &*pair;
let mut ready = lock.lock().unwrap();
*ready = true;
cvar.notify_one(); // Будим один ожидающий поток (notify_all() будит все)
}
worker.join().unwrap();
}
Когда использовать Condvar, а когда каналы: используйте
Condvar, когда потоки разделяют изменяемое состояние и должны ждать условия на этом состоянии (например, «буфер не пуст»). Используйте каналы (mpsc), когда потокам нужно передавать сообщения. Каналы обычно проще для рассуждений.
Конкурентность в Rust: каналы
- Каналы Rust позволяют обмениваться сообщениями между
SenderиReceiver- Используется парадигма под названием
mpscилиMulti-producer, Single-Consumer(много производителей, один потребитель) - И
send(), иrecv()могут блокировать поток
- Используется парадигма под названием
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());
}
Конкурентность в Rust: каналы и потоки
- Каналы можно комбинировать с потоками
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel();
for _ in 0..2 {
let tx2 = tx.clone();
thread::spawn(move || {
let thread_id = thread::current().id();
for i in 0..10 {
tx2.send(format!("Message {i}")).unwrap();
println!("{thread_id:?}: sent Message {i}");
}
println!("{thread_id:?}: done");
});
}
// Удаляем исходного отправителя, чтобы rx.iter() завершился, когда все клонированные отправители будут уничтожены
drop(tx);
thread::sleep(Duration::from_millis(100));
for msg in rx.iter() {
println!("Main: got {msg}");
}
}
Почему Rust предотвращает гонки данных: Send и Sync
- Rust использует два маркерных трейта, чтобы обеспечивать потокобезопасность на этапе компиляции:
Send: тип являетсяSend, если его можно безопасно передать в другой потокSync: тип являетсяSync, если его можно безопасно разделять (через&T) между потоками
- Большинство типов автоматически являются
Send + Sync. Заметные исключения:Rc<T>не является ни Send, ни Sync (для потоков используйтеArc<T>)Cell<T>иRefCell<T>не являются Sync (используйтеMutex<T>илиRwLock<T>)- Сырые указатели (
*const T,*mut T) не являются ни Send, ни Sync
- Именно поэтому компилятор не даёт использовать
Rc<T>между потоками — он просто не реализуетSend Arc<Mutex<T>>— потокобезопасный аналогRc<RefCell<T>>
Интуиция (Jon Gjengset): представьте значения как игрушки.
Send= вы можете отдать свою игрушку другому ребёнку (потоку) — передача владения безопасна.Sync= вы можете позволить другим играть с вашей игрушкой одновременно — совместное использование ссылки безопасно. УRc<T>хрупкий (неатомарный) счётчик ссылок; передача или совместное использование повредили бы счётчик, поэтому он не является ниSend, ниSync.
Упражнение: подсчёт слов в многопоточном режиме
🔴 Сложный уровень — объединяет потоки, Arc, Mutex и HashMap
- Дан
Vec<String>со строками текста. Запустите по одному потоку на каждую строку, чтобы подсчитать слова в ней - Используйте
Arc<Mutex<HashMap<String, usize>>>, чтобы собрать результаты - Выведите общее количество слов по всем строкам
- Бонус: попробуйте реализовать то же самое с каналами (
mpsc) вместо разделяемого состояния
Решение (нажмите, чтобы развернуть)
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let lines = vec![
"the quick brown fox".to_string(),
"jumps over the lazy dog".to_string(),
"the fox is quick".to_string(),
];
let word_counts: Arc<Mutex<HashMap<String, usize>>> =
Arc::new(Mutex::new(HashMap::new()));
let mut handles = vec![];
for line in &lines {
let line = line.clone();
let counts = Arc::clone(&word_counts);
handles.push(thread::spawn(move || {
for word in line.split_whitespace() {
let mut map = counts.lock().unwrap();
*map.entry(word.to_lowercase()).or_insert(0) += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
let counts = word_counts.lock().unwrap();
let total: usize = counts.values().sum();
println!("Word frequencies: {counts:#?}");
println!("Total words: {total}");
}
// Вывод (порядок может отличаться):
// Word frequencies: {
// "the": 3,
// "quick": 2,
// "brown": 1,
// "fox": 2,
// "jumps": 1,
// "over": 1,
// "lazy": 1,
// "dog": 1,
// "is": 1,
// }
// Total words: 13
14. Unsafe Rust и FFI
Небезопасный Rust (unsafe)
Что вы узнаете: когда и как использовать
unsafe— разыменование сырых указателей, FFI (Foreign Function Interface) для вызова C из Rust и наоборот,CString/CStrдля взаимодействия со строками и как писать безопасные обёртки вокруг небезопасного кода.
unsafeоткрывает доступ к возможностям, которые обычно запрещены компилятором Rust- Разыменование сырых указателей
- Доступ к изменяемым статическим переменным
- https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html
- С большой силой приходит большая ответственность
unsafeговорит компилятору: «Я, программист, беру на себя ответственность за соблюдение инвариантов, которые компилятор обычно гарантирует»- Нужно гарантировать отсутствие одновременных изменяемых и неизменяемых псевдонимов ссылок, висячих указателей, некорректных ссылок и т. д.
- Использование
unsafeследует ограничивать как можно меньшей областью - У всего кода с
unsafeдолжен быть комментарий «safety», описывающий предположения
Примеры unsafe в Rust
unsafe fn harmless() {}
fn main() {
// Safety: вызываем безобидную небезопасную функцию
unsafe {
harmless();
}
let a = 42u32;
let p = &a as *const u32;
// Safety: p — корректный указатель на переменную, которая останется в области видимости
unsafe {
println!("{}", *p);
}
// Safety: небезопасно; только для иллюстрации
let dangerous_buffer = 0xb8000 as *mut u32;
unsafe {
println!("About to go kaboom!!!");
*dangerous_buffer = 0; // На большинстве современных машин это вызовет SEGV
}
}
Простой пример FFI (функция Rust, вызываемая из C)
Строки FFI: CString и CStr
FFI расшифровывается как Foreign Function Interface (интерфейс внешних функций) — механизм, с помощью которого Rust вызывает функции, написанные на других языках (например, C), и наоборот.
При взаимодействии с кодом на C типы String и &str в Rust (которые являются UTF-8 без завершающего нуля) напрямую несовместимы с C-строками (которые представляют собой массивы байтов с завершающим нулём). Для этого в std::ffi Rust предоставляет CString (владеющий) и CStr (заимствованный):
| Тип | Аналог | Когда использовать |
|---|---|---|
CString | String (владеющий) | Создание C-строки из данных Rust |
&CStr | &str (заимствованный) | Получение C-строки из внешнего кода |
#![allow(unused)]
fn main() {
use std::ffi::{CString, CStr};
use std::os::raw::c_char;
fn demo_ffi_strings() {
// Создаём совместимую с C строку (добавляется завершающий нуль)
let c_string = CString::new("Hello from Rust").expect("CString::new failed");
let ptr: *const c_char = c_string.as_ptr();
// Преобразуем C-строку обратно в Rust (unsafe, потому что мы доверяем указателю)
// Safety: ptr валиден и заканчивается нулём (мы только что его создали)
let back_to_rust: &CStr = unsafe { CStr::from_ptr(ptr) };
let rust_str: &str = back_to_rust.to_str().expect("Invalid UTF-8");
println!("{}", rust_str);
}
}
Предупреждение:
CString::new()вернёт ошибку, если входные данные содержат внутренние нулевые байты (\0). Всегда обрабатывайтеResult.CStrактивно используется в примерах FFI ниже.
- Методы
FFIдолжны быть помечены#[no_mangle], чтобы компилятор не искажал их имена - Скомпилируем крейт как статическую библиотеку
#[no_mangle] pub extern "C" fn add(left: u64, right: u64) -> u64 { left + right } - Скомпилируем следующий C-код и скомпонуем его с нашей статической библиотекой
#include <stdio.h> #include <stdint.h> extern uint64_t add(uint64_t, uint64_t); int main() { printf("Add returned %llu\n", add(21, 21)); }
Сложный пример FFI
- В следующих примерах мы создадим интерфейс логирования на Rust и предоставим его для [PYTHON] и
C- Мы увидим, как один и тот же интерфейс можно использовать нативно из Rust и из C
- Мы рассмотрим инструменты вроде
cbindgen`` для генерации заголовочных файлов дляC``` - Мы увидим, как обёртки
unsafeслужат мостом к безопасному коду на Rust
Вспомогательные функции логгера
#![allow(unused)]
fn main() {
fn create_or_open_log_file(log_file: &str, overwrite: bool) -> Result<File, String> {
if overwrite {
File::create(log_file).map_err(|e| e.to_string())
} else {
OpenOptions::new()
.write(true)
.append(true)
.open(log_file)
.map_err(|e| e.to_string())
}
}
fn log_to_file(file_handle: &mut File, message: &str) -> Result<(), String> {
file_handle
.write_all(message.as_bytes())
.map_err(|e| e.to_string())
}
}
Структура логгера
#![allow(unused)]
fn main() {
struct SimpleLogger {
log_level: LogLevel,
file_handle: File,
}
impl SimpleLogger {
fn new(log_file: &str, overwrite: bool, log_level: LogLevel) -> Result<Self, String> {
let file_handle = create_or_open_log_file(log_file, overwrite)?;
Ok(Self {
file_handle,
log_level,
})
}
fn log_message(&mut self, log_level: LogLevel, message: &str) -> Result<(), String> {
if log_level as u32 <= self.log_level as u32 {
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let message = format!("Simple: {timestamp} {log_level} {message}\n");
log_to_file(&mut self.file_handle, &message)
} else {
Ok(())
}
}
}
}
Тестирование
- Тестирование в Rust — это просто
- Тестовые функции помечаются
#[test]и не входят в скомпилированный бинарный файл - Легко создавать мок-методы для тестов
- Тестовые функции помечаются
#![allow(unused)]
fn main() {
#[test]
fn testfunc() -> Result<(), String> {
let mut logger = SimpleLogger::new("test.log", false, LogLevel::INFO)?;
logger.log_message(LogLevel::TRACELEVEL1, "Hello world")?;
logger.log_message(LogLevel::CRITICAL, "Critical message")?;
Ok(()) // Компилятор автоматически уничтожает logger здесь
}
}
cargo test
FFI: вызов из C в Rust
- cbindgen — отличный инструмент для генерации заголовочных файлов для экспортируемых функций Rust
- Устанавливается через cargo
cargo install cbindgen
cbindgen
- Функции и структуры экспортируются с помощью
#[no_mangle]и#[repr(C)]- Мы будем использовать распространённый шаблон интерфейса: передаём
**в реализацию и возвращаем 0 при успехе и ненулевое значение при ошибке - Непрозрачные и прозрачные структуры: наш
SimpleLoggerпередаётся как непрозрачный указатель (*mut SimpleLogger) — сторона C никогда не обращается к его полям, поэтому#[repr(C)]не нужен. Используйте#[repr(C)], когда код на C должен напрямую читать и записывать поля структуры:
- Мы будем использовать распространённый шаблон интерфейса: передаём
#![allow(unused)]
fn main() {
// Непрозрачная — C хранит только указатель и никогда не проверяет поля. #[repr(C)] не нужен.
struct SimpleLogger { /* Поля только для Rust */ }
// Прозрачная — C читает и записывает поля напрямую. ОБЯЗАТЕЛЬНО #[repr(C)].
#[repr(C)]
pub struct Point {
pub x: f64,
pub y: f64,
}
}
typedef struct SimpleLogger SimpleLogger;
uint32_t create_simple_logger(const char *file_name, struct SimpleLogger **out_logger);
uint32_t log_entry(struct SimpleLogger *logger, const char *message);
uint32_t drop_logger(struct SimpleLogger *logger);
- Обратите внимание, что нам нужно много проверок на корректность
- Мы должны явно «утечь» память, чтобы Rust не освободил её автоматически
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn create_simple_logger(file_name: *const std::os::raw::c_char, out_logger: *mut *mut SimpleLogger) -> u32 {
use std::ffi::CStr;
// Убеждаемся, что указатель не NULL
if file_name.is_null() || out_logger.is_null() {
return 1;
}
// Safety: переданный указатель либо NULL, либо по контракту заканчивается нулём
let file_name = unsafe {
CStr::from_ptr(file_name)
};
let file_name = file_name.to_str();
// Убеждаемся, что в file_name нет мусорных символов
if file_name.is_err() {
return 1;
}
let file_name = file_name.unwrap();
// Задаём значения по умолчанию; в реальности их следовало бы передавать
let new_logger = SimpleLogger::new(file_name, true, LogLevel::CRITICAL);
// Проверяем, что логгер удалось создать
if new_logger.is_err() {
return 1;
}
let new_logger = Box::new(new_logger.unwrap());
// Это не даёт Box уничтожиться при выходе из области видимости
let logger_ptr: *mut SimpleLogger = Box::leak(new_logger);
// Safety: logger не NULL, а logger_ptr валиден
unsafe {
*out_logger = logger_ptr;
}
return 0;
}
}
- Аналогичные проверки ошибок есть и в
log_entry()
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn log_entry(logger: *mut SimpleLogger, message: *const std::os::raw::c_char) -> u32 {
use std::ffi::CStr;
if message.is_null() || logger.is_null() {
return 1;
}
// Safety: message не NULL
let message = unsafe {
CStr::from_ptr(message)
};
let message = message.to_str();
// Убеждаемся, что в сообщении нет мусорных символов
if message.is_err() {
return 1;
}
// Safety: logger — валидный указатель, ранее созданный create_simple_logger()
unsafe {
(*logger).log_message(LogLevel::CRITICAL, message.unwrap()).is_err() as u32
}
}
#[no_mangle]
pub extern "C" fn drop_logger(logger: *mut SimpleLogger) -> u32 {
if logger.is_null() {
return 1;
}
// Safety: logger — валидный указатель, ранее созданный create_simple_logger()
unsafe {
// Это создаёт Box<SimpleLogger>, который уничтожается при выходе из области видимости
let _ = Box::from_raw(logger);
}
0
}
}
- Тестировать наш FFI (C) можно средствами Rust или написав программу на C
#![allow(unused)]
fn main() {
#[test]
fn test_c_logger() {
// Конструкция c".." создаёт строку с завершающим нулём
let file_name = c"test.log".as_ptr() as *const std::os::raw::c_char;
let mut c_logger: *mut SimpleLogger = std::ptr::null_mut();
assert_eq!(create_simple_logger(file_name, &mut c_logger), 0);
// Ручной способ создать строку c"..."
let message = b"message from C\0".as_ptr() as *const std::os::raw::c_char;
assert_eq!(log_entry(c_logger, message), 0);
drop_logger(c_logger);
}
}
#include "logger.h"
...
int main() {
SimpleLogger *logger = NULL;
if (create_simple_logger("test.log", &logger) == 0) {
log_entry(logger, "Hello from C");
drop_logger(logger); /* Необходимо для закрытия дескриптора и т. д. */
}
...
}
Обеспечение корректности unsafe-кода
- Если коротко: использование
unsafeтребует осознанных размышлений- Всегда документируйте предположения о безопасности, которые делает код, и проверяйте его с экспертами
- Используйте инструменты вроде cbindgen, Miri, Valgrind, которые помогают проверять корректность
- Никогда не позволяйте panic раскручиваться через границу FFI — это неопределённое поведение. Используйте
std::panic::catch_unwindв точках входа FFI или настройтеpanic = "abort"в профиле - Если структура используется через FFI, пометьте её
#[repr(C)], чтобы гарантировать совместимую с C раскладку в памяти - Обратитесь к https://doc.rust-lang.org/nomicon/intro.html («Rustonomicon» — тёмные искусства unsafe Rust)
- Обращайтесь за помощью к внутренним экспертам
Инструменты проверки: Miri и Valgrind
Программисты C++ знакомы с Valgrind и санитайзерами. У Rust есть всё это плюс Miri, который гораздо точнее для UB, специфичного для Rust:
| Miri | Valgrind | Санитайзеры C++ (ASan/MSan/UBSan) | |
|---|---|---|---|
| Что находит | UB, специфичное для Rust: stacked borrows, некорректные дискриминанты enum, чтение неинициализированных данных, нарушения алиасинга | Утечки памяти, использование после освобождения, некорректные чтения/записи, неинициализированная память | Переполнение буфера, использование после освобождения, гонки данных, UB |
| Как работает | Интерпретирует MIR (промежуточное представление Rust) — без нативного выполнения | Инструментирует скомпилированный бинарный файл во время выполнения | Инструментирование на этапе компиляции |
| Поддержка FFI | ❌ Не может пересекать границу FFI (пропускает вызовы C) | ✅ Работает с любым скомпилированным бинарным файлом, включая FFI | ✅ Работает, если код C тоже скомпилирован с санитайзерами |
| Скорость | ~100x медленнее нативного | ~10-50x медленнее | ~2-5x медленнее |
| Когда использовать | Чистый Rust unsafe-код, инварианты структур данных | Код FFI, полные интеграционные тесты бинарного файла | Сторона C/C++ в FFI, тестирование, чувствительное к производительности |
| Находит ошибки алиасинга | ✅ Модель Stacked Borrows | ❌ | Частично (TSan для гонок данных) |
Рекомендация: используйте оба — Miri для чистого unsafe Rust, Valgrind для интеграции FFI:
-
Miri — находит UB, специфичное для Rust, которое Valgrind не видит (нарушения алиасинга, некорректные значения enum, stacked borrows):
rustup +nightly component add miri cargo +nightly miri test # Запустить все тесты под Miri cargo +nightly miri test -- test_name # Запустить конкретный тест⚠️ Miri требует nightly и не может выполнять вызовы FFI. Выносите логику unsafe-Rust в тестируемые модули.
-
Valgrind — инструмент, который вы уже знаете; работает со скомпилированным бинарным файлом, включая FFI:
sudo apt install valgrind cargo install cargo-valgrind cargo valgrind test # Запустить все тесты под ValgrindНаходит утечки в шаблонах
Box::leak/Box::from_raw, которые распространены в коде FFI. -
cargo-careful — запускает тесты с дополнительными проверками времени выполнения (между обычными тестами и Miri):
cargo install cargo-careful cargo +nightly careful test
Итоги по unsafe Rust
cbindgen— отличный инструмент для FFI (C) в сторону Rust- Для FFI-интерфейсов в обратную сторону используйте
bindgen(см. обширную документацию)
- Для FFI-интерфейсов в обратную сторону используйте
- Не предполагайте, что ваш unsafe-код корректен или что его безопасно использовать из безопасного Rust. Ошибиться очень легко, и даже код, который внешне работает правильно, может быть неверным по тонким причинам
- Используйте инструменты для проверки корректности
- Если сомневаетесь, обратитесь за экспертным советом
- Убедитесь, что ваш код с
unsafeсодержит комментарии с явным описанием предположений и причин, по которым он корректен- У вызывающего кода
unsafeтоже должны быть соответствующие комментарии о безопасности, и он должен соблюдать ограничения
- У вызывающего кода
Упражнение: написание безопасной обёртки FFI
🔴 Сложный уровень — требует понимания блоков unsafe, сырых указателей и проектирования безопасного API
- Напишите безопасную обёртку на Rust вокруг функции
unsafeв стиле FFI. Упражнение имитирует вызов функции на C, которая записывает отформатированную строку в буфер, предоставленный вызывающей стороной - Шаг 1: реализуйте небезопасную функцию
unsafe_greet, которая записывает приветствие в сырой буфер*mut u8 - Шаг 2: напишите безопасную обёртку
safe_greet, которая выделяетVec<u8>, вызывает небезопасную функцию и возвращаетString - Шаг 3: добавьте корректные комментарии
// Safety:к каждому блоку unsafe
Стартовый код:
use std::fmt::Write as _;
/// Имитирует функцию на C: записывает "Hello, <name>!" в буфер.
/// Возвращает количество записанных байтов (без нулевого терминатора).
/// # Safety
/// - `buf` должен указывать на не менее чем `buf_len` доступных для записи байтов
/// - `name` должен быть валидным указателем на C-строку с завершающим нулём
unsafe fn unsafe_greet(buf: *mut u8, buf_len: usize, name: *const u8) -> isize {
// TODO: Сформируйте приветствие, скопируйте байты в buf, верните длину
// Подсказка: используйте std::ffi::CStr::from_ptr или перебирайте байты вручную
todo!()
}
/// Безопасная обёртка — в публичном API нет unsafe
fn safe_greet(name: &str) -> Result<String, String> {
// TODO: Выделите буфер Vec<u8>, создайте C-строку с нулём в конце для имени,
// вызовите unsafe_greet внутри блока unsafe с комментарием Safety,
// преобразуйте результат обратно в String
todo!()
}
fn main() {
match safe_greet("Rustacean") {
Ok(msg) => println!("{msg}"),
Err(e) => eprintln!("Error: {e}"),
}
// Ожидаемый вывод: Hello, Rustacean!
}
Решение (нажмите, чтобы развернуть)
use std::ffi::CStr;
/// Имитирует функцию на C: записывает "Hello, <name>!" в буфер.
/// Возвращает количество записанных байтов или -1, если буфер слишком мал.
/// # Safety
/// - `buf` должен указывать на не менее чем `buf_len` доступных для записи байтов
/// - `name` должен быть валидным указателем на C-строку с завершающим нулём
unsafe fn unsafe_greet(buf: *mut u8, buf_len: usize, name: *const u8) -> isize {
// Safety: вызывающая сторона гарантирует, что name — валидная строка с завершающим нулём
let name_cstr = unsafe { CStr::from_ptr(name as *const std::os::raw::c_char) };
let name_str = match name_cstr.to_str() {
Ok(s) => s,
Err(_) => return -1,
};
let greeting = format!("Hello, {}!", name_str);
if greeting.len() > buf_len {
return -1;
}
// Safety: buf указывает на не менее чем buf_len доступных для записи байтов (гарантия вызывающей стороны)
unsafe {
std::ptr::copy_nonoverlapping(greeting.as_ptr(), buf, greeting.len());
}
greeting.len() as isize
}
/// Безопасная обёртка — в публичном API нет unsafe
fn safe_greet(name: &str) -> Result<String, String> {
let mut buffer = vec![0u8; 256];
// Создаём версию name с завершающим нулём для C API
let name_with_null: Vec<u8> = name.bytes().chain(std::iter::once(0)).collect();
// Safety: в buffer 256 доступных для записи байтов, name_with_null заканчивается нулём
let bytes_written = unsafe {
unsafe_greet(buffer.as_mut_ptr(), buffer.len(), name_with_null.as_ptr())
};
if bytes_written < 0 {
return Err("Buffer too small or invalid name".to_string());
}
String::from_utf8(buffer[..bytes_written as usize].to_vec())
.map_err(|e| format!("Invalid UTF-8: {e}"))
}
fn main() {
match safe_greet("Rustacean") {
Ok(msg) => println!("{msg}"),
Err(e) => eprintln!("Error: {e}"),
}
}
// Вывод:
// Hello, Rustacean!
no_std — Rust без стандартной библиотеки
Что вы узнаете: как писать на Rust под bare-metal и встраиваемые платформы с помощью
#![no_std]— разделение крейтовcoreиalloc, обработчики panic и то, как это сравнивается со встраиваемым C безlibc.
Если вы работаете со встраиваемым C, вам уже привычно работать без libc или с минимальной средой выполнения. У Rust есть полноценный аналог: атрибут #![no_std].
Что такое no_std?
Когда вы добавляете #![no_std] в корень крейта, компилятор убирает неявное extern crate std; и линкуется только с core (и, при необходимости, с alloc).
| Уровень | Что предоставляет | Требует ОС / кучу? |
|---|---|---|
core | Примитивные типы, Option, Result, Iterator, математика, slice, str, атомарные операции, fmt | Нет — работает на bare metal |
alloc | Vec, String, Box, Rc, Arc, BTreeMap | Нужен глобальный аллокатор, но без ОС |
std | HashMap, fs, net, thread, io, env, process | Да — нужна ОС |
Эмпирическое правило для встраиваемых разработчиков: если ваш проект на C линкуется с
-lcи используетmalloc, скорее всего, можно использоватьcore+alloc. Если же он работает на bare metal безmalloc, оставайтесь только наcore.
Объявление no_std
#![allow(unused)]
fn main() {
// src/lib.rs (или src/main.rs для бинарника с #![no_main])
#![no_std]
// Всё из `core` вам по-прежнему доступно:
use core::fmt;
use core::result::Result;
use core::option::Option;
// Если есть аллокатор, подключаем типы, работающие с кучей:
extern crate alloc;
use alloc::vec::Vec;
use alloc::string::String;
}
Для bare-metal бинарника также нужны #![no_main] и обработчик panic:
#![allow(unused)]
#![no_std]
#![no_main]
fn main() {
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {} // Зависаем при panic — замените на перезагрузку платы или мигание светодиодом
}
// Точка входа зависит от вашего HAL / скрипта компоновщика
}
Что вы теряете (и альтернативы)
Возможность std | Альтернатива в no_std |
|---|---|
println! | core::write! в UART / defmt |
HashMap | heapless::FnvIndexMap (фиксированная ёмкость) или BTreeMap (с alloc) |
Vec | heapless::Vec (на стеке, фиксированная ёмкость) |
String | heapless::String или &str |
std::io::Read/Write | embedded_io::Read/Write |
thread::spawn | Обработчики прерываний, задачи RTIC |
std::time | Аппаратные таймерные периферийные блоки |
std::fs | Драйверы flash / EEPROM |
Заметные крейты no_std для встраиваемых систем
| Крейт | Назначение | Примечания |
|---|---|---|
heapless | Vec, String, Queue, Map фиксированной ёмкости | Аллокатор не нужен — всё на стеке |
defmt | Эффективное логирование через probe/ITM | Как printf, но форматирование откладывается на хост |
embedded-hal | Трейты абстракции оборудования (SPI, I²C, GPIO, UART) | Реализуете один раз — работает на любом MCU |
cortex-m | Интринсики и доступ к регистрам ARM Cortex-M | Низкоуровневый, как CMSIS |
cortex-m-rt | Среда выполнения / стартовый код для Cortex-M | Заменяет ваш startup.s |
rtic | Real-Time Interrupt-driven Concurrency | Планирование задач на этапе компиляции, без накладных расходов |
embassy | Асинхронный исполнитель для встраиваемых систем | async/await на bare metal |
postcard | no_std сериализация serde (бинарная) | Заменяет serde_json, когда строки не по карману |
thiserror | Derive-макрос для трейта Error | Работает в no_std начиная с v2; предпочтительнее anyhow |
smoltcp | Стек TCP/IP на no_std | Когда нужна сеть без ОС |
C и Rust: сравнение для bare metal
Типичный встраиваемый «blinky» на C:
// C — bare metal, вендорский HAL
#include "stm32f4xx_hal.h"
void SysTick_Handler(void) {
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);
}
int main(void) {
HAL_Init();
__HAL_RCC_GPIOA_CLK_ENABLE();
GPIO_InitTypeDef gpio = { .Pin = GPIO_PIN_5, .Mode = GPIO_MODE_OUTPUT_PP };
HAL_GPIO_Init(GPIOA, &gpio);
HAL_SYSTICK_Config(HAL_RCC_GetHCLKFreq() / 1000);
while (1) {}
}
Эквивалент на Rust (с использованием embedded-hal и крейта платы):
#![no_std]
#![no_main]
use cortex_m_rt::entry;
use panic_halt as _; // обработчик panic: бесконечный цикл
use stm32f4xx_hal::{pac, prelude::*};
#[entry]
fn main() -> ! {
let dp = pac::Peripherals::take().unwrap();
let gpioa = dp.GPIOA.split();
let mut led = gpioa.pa5.into_push_pull_output();
let rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.freeze();
let mut delay = dp.TIM2.delay_ms(&clocks);
loop {
led.toggle();
delay.delay_ms(500u32);
}
}
Ключевые отличия для разработчиков на C:
Peripherals::take()возвращаетOption— это обеспечивает паттерн синглтона на этапе компиляции (без ошибок двойной инициализации).split()передаёт владение отдельными выводами — нет риска, что два модуля будут управлять одним выводом- Весь доступ к регистрам проверяется типами — вы не сможете случайно записать в регистр только для чтения
- Проверка заимствований предотвращает гонки данных между
mainи обработчиками прерываний (с RTIC)
Когда использовать no_std, а когда std
flowchart TD
A[Есть ли у вашей целевой платформы ОС?] -->|Да| B[Используйте std]
A -->|Нет| C[Есть ли у вас аллокатор кучи?]
C -->|Да| D["Используйте #![no_std] + extern crate alloc"]
C -->|Нет| E["Используйте #![no_std] только с core"]
B --> F[Полные Vec, HashMap, потоки, fs, net]
D --> G[Vec, String, Box, BTreeMap — без fs/net/потоков]
E --> H[Массивы фиксированного размера, коллекции heapless, без выделения памяти]
Упражнение: кольцевой буфер no_std
🔴 Сложный уровень — объединяет обобщения, MaybeUninit и #[cfg(test)] в контексте no_std
Во встраиваемых системах часто нужен кольцевой буфер фиксированного размера, который
никогда не выделяет память. Реализуйте его, используя только core (без alloc и без std).
Требования:
- Обобщённый по типу элемента
T: Copy - Фиксированная ёмкость
N(константный обобщённый параметр) push(&mut self, item: T)— перезаписывает самый старый элемент, когда буфер заполненpop(&mut self) -> Option<T>— возвращает самый старый элементlen(&self) -> usizeis_empty(&self) -> bool- Должен компилироваться с
#![no_std]
#![allow(unused)]
fn main() {
// Стартовый код
#![no_std]
use core::mem::MaybeUninit;
pub struct RingBuffer<T: Copy, const N: usize> {
buf: [MaybeUninit<T>; N],
head: usize, // позиция следующей записи
tail: usize, // позиция следующего чтения
count: usize,
}
impl<T: Copy, const N: usize> RingBuffer<T, N> {
pub const fn new() -> Self {
todo!()
}
pub fn push(&mut self, item: T) {
todo!()
}
pub fn pop(&mut self) -> Option<T> {
todo!()
}
pub fn len(&self) -> usize {
todo!()
}
pub fn is_empty(&self) -> bool {
todo!()
}
}
}
Решение
#![allow(unused)]
#![no_std]
fn main() {
use core::mem::MaybeUninit;
pub struct RingBuffer<T: Copy, const N: usize> {
buf: [MaybeUninit<T>; N],
head: usize,
tail: usize,
count: usize,
}
impl<T: Copy, const N: usize> RingBuffer<T, N> {
pub const fn new() -> Self {
Self {
// SAFETY: MaybeUninit не требует инициализации
buf: unsafe { MaybeUninit::uninit().assume_init() },
head: 0,
tail: 0,
count: 0,
}
}
pub fn push(&mut self, item: T) {
self.buf[self.head] = MaybeUninit::new(item);
self.head = (self.head + 1) % N;
if self.count == N {
// Буфер заполнен — перезаписываем самый старый элемент, сдвигаем tail
self.tail = (self.tail + 1) % N;
} else {
self.count += 1;
}
}
pub fn pop(&mut self) -> Option<T> {
if self.count == 0 {
return None;
}
// SAFETY: читаем только те позиции, которые ранее были записаны через push()
let item = unsafe { self.buf[self.tail].assume_init() };
self.tail = (self.tail + 1) % N;
self.count -= 1;
Some(item)
}
pub fn len(&self) -> usize {
self.count
}
pub fn is_empty(&self) -> bool {
self.count == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_push_pop() {
let mut rb = RingBuffer::<u32, 4>::new();
assert!(rb.is_empty());
rb.push(10);
rb.push(20);
rb.push(30);
assert_eq!(rb.len(), 3);
assert_eq!(rb.pop(), Some(10));
assert_eq!(rb.pop(), Some(20));
assert_eq!(rb.pop(), Some(30));
assert_eq!(rb.pop(), None);
}
#[test]
fn overwrite_on_full() {
let mut rb = RingBuffer::<u8, 3>::new();
rb.push(1);
rb.push(2);
rb.push(3);
// Буфер заполнен: [1, 2, 3]
rb.push(4); // Перезаписывает 1 → [4, 2, 3], tail сдвигается
assert_eq!(rb.len(), 3);
assert_eq!(rb.pop(), Some(2)); // самый старый из оставшихся
assert_eq!(rb.pop(), Some(3));
assert_eq!(rb.pop(), Some(4));
assert_eq!(rb.pop(), None);
}
}
}
Почему это важно для разработчиков встраиваемого C:
MaybeUninit— это аналог неинициализированной памяти в Rust: компилятор не вставляет заполнение нулями, так же как вchar buf[N];в C- Блоки
unsafeминимальны (по 2 строки), и у каждого есть комментарий// SAFETY: const fn new()означает, что кольцевые буферы можно создавать в переменныхstaticбез конструктора времени выполнения- Тесты запускаются на вашей машине командой
cargo test, хотя код рассчитан наno_std
Углублённо о встраиваемых системах
Доступ к MMIO и volatile-регистрам
Что вы узнаете: типобезопасный доступ к аппаратным регистрам во встраиваемом Rust — шаблоны volatile MMIO, крейты абстракции регистров и то, как система типов Rust может кодировать права доступа к регистрам, чего не умеет ключевое слово
volatileиз C.
В прошивке на C вы обращаетесь к аппаратным регистрам через volatile-указатели на конкретные
адреса памяти. В Rust есть эквивалентные механизмы — но с типобезопасностью.
volatile в C и volatile в Rust
// C — типичный доступ к регистрам MMIO
#define GPIO_BASE 0x40020000
#define GPIO_MODER (*(volatile uint32_t*)(GPIO_BASE + 0x00))
#define GPIO_ODR (*(volatile uint32_t*)(GPIO_BASE + 0x14))
void toggle_led(void) {
GPIO_ODR ^= (1 << 5); // Переключаем пин 5
}
#![allow(unused)]
fn main() {
// Rust — сырой volatile (низкий уровень, редко используется напрямую)
use core::ptr;
const GPIO_BASE: usize = 0x4002_0000;
const GPIO_ODR: *mut u32 = (GPIO_BASE + 0x14) as *mut u32;
/// # Safety
/// Вызывающая сторона должна гарантировать, что GPIO_BASE — валидный отображённый периферийный адрес.
unsafe fn toggle_led() {
// SAFETY: GPIO_ODR — валидный адрес регистра, отображённого в память.
let current = unsafe { ptr::read_volatile(GPIO_ODR) };
unsafe { ptr::write_volatile(GPIO_ODR, current ^ (1 << 5)) };
}
}
svd2rust — типобезопасный доступ к регистрам (путь Rust)
На практике вы никогда не пишете сырые volatile-указатели. Вместо этого svd2rust генерирует
крейт доступа к периферии (PAC, Peripheral Access Crate) из SVD-файла чипа (того же XML-файла,
который использует отладочное представление вашей IDE):
#![allow(unused)]
fn main() {
// Сгенерированный код PAC (вы его не пишете — это делает svd2rust)
// PAC превращает некорректный доступ к регистрам в ошибку компиляции
// Использование PAC:
use stm32f4::stm32f401; // Крейт PAC для вашего чипа
fn configure_gpio(dp: stm32f401::Peripherals) {
// Включаем тактирование GPIOA — типобезопасно, без магических чисел
dp.RCC.ahb1enr.modify(|_, w| w.gpioaen().enabled());
// Настраиваем пин 5 на выход — нельзя случайно записать в поле только для чтения
dp.GPIOA.moder.modify(|_, w| w.moder5().output());
// Переключаем пин 5 — доступ к полю проверяется типами
dp.GPIOA.odr.modify(|r, w| {
// SAFETY: переключаем один бит в корректном поле регистра.
unsafe { w.bits(r.bits() ^ (1 << 5)) }
});
}
}
| Доступ к регистрам в C | Аналог в Rust PAC |
|---|---|
#define REG (*(volatile uint32_t*)ADDR) | Крейт PAC, сгенерированный svd2rust |
| `REG | = BITMASK;` |
value = REG; | let val = periph.reg.read().field().bits() |
| Неверное поле регистра → молчаливое UB | Ошибка компиляции — поля не существует |
| Неверная ширина регистра → молчаливое UB | Проверяется типами — u8, u16 или u32 |
Обработка прерываний и критические секции
Прошивка на C использует __disable_irq() / __enable_irq() и функции ISR с сигнатурами void.
Rust предоставляет типобезопасные эквиваленты.
Шаблоны прерываний в C и Rust
// C — традиционный обработчик прерывания
volatile uint32_t tick_count = 0;
void SysTick_Handler(void) { // Имя соглашения критично — ошибка → HardFault
tick_count++;
}
uint32_t get_ticks(void) {
__disable_irq();
uint32_t t = tick_count; // Чтение внутри критической секции
__enable_irq();
return t;
}
#![allow(unused)]
fn main() {
// Rust — с использованием cortex-m и критических секций
use core::cell::Cell;
use cortex_m::interrupt::{self, Mutex};
// Разделяемое состояние, защищённое Mutex на основе критической секции
static TICK_COUNT: Mutex<Cell<u32>> = Mutex::new(Cell::new(0));
#[cortex_m_rt::exception] // Атрибут обеспечивает правильное размещение в таблице векторов
fn SysTick() { // Ошибка компиляции, если имя не совпадает с допустимым исключением
interrupt::free(|cs| { // cs — токен критической секции (доказательство, что IRQ запрещены)
let count = TICK_COUNT.borrow(cs).get();
TICK_COUNT.borrow(cs).set(count + 1);
});
}
fn get_ticks() -> u32 {
interrupt::free(|cs| TICK_COUNT.borrow(cs).get())
}
}
RTIC — конкурентность в реальном времени, управляемая прерываниями
Для сложной прошивки с несколькими приоритетами прерываний RTIC (ранее RTFM) предоставляет планирование задач на этапе компиляции без накладных расходов:
#![allow(unused)]
fn main() {
#[rtic::app(device = stm32f4xx_hal::pac, dispatchers = [USART1])]
mod app {
use stm32f4xx_hal::prelude::*;
#[shared]
struct Shared {
temperature: f32, // Разделяется между задачами — RTIC управляет блокировками
}
#[local]
struct Local {
led: stm32f4xx_hal::gpio::Pin<'A', 5, stm32f4xx_hal::gpio::Output>,
}
#[init]
fn init(cx: init::Context) -> (Shared, Local) {
let dp = cx.device;
let gpioa = dp.GPIOA.split();
let led = gpioa.pa5.into_push_pull_output();
(Shared { temperature: 25.0 }, Local { led })
}
// Аппаратная задача: выполняется по прерыванию SysTick
#[task(binds = SysTick, shared = [temperature], local = [led])]
fn tick(mut cx: tick::Context) {
cx.local.led.toggle();
cx.shared.temperature.lock(|temp| {
// RTIC гарантирует эксклюзивный доступ здесь — ручная блокировка не нужна
*temp += 0.1;
});
}
}
}
Почему RTIC важен для разработчиков прошивок на C:
- Аннотация
#[shared]заменяет ручное управление мьютексами - Вытесняющее планирование по приоритетам настраивается на этапе компиляции — без накладных расходов во время выполнения
- Взаимная блокировка исключена по построению (фреймворк доказывает это на этапе компиляции)
- Ошибки в именах обработчиков прерываний — это ошибки компиляции, а не сбои HardFault во время выполнения
Стратегии обработки panic
В C, когда в прошивке что-то идёт не так, обычно выполняют сброс или мигают светодиодом. Обработчик panic в Rust даёт структурированный контроль:
#![allow(unused)]
fn main() {
// Стратегия 1: остановка (для отладки — подключаем отладчик, смотрим состояние)
use panic_halt as _; // Бесконечный цикл при panic
// Стратегия 2: сброс MCU
use panic_reset as _; // Вызывает системный сброс
// Стратегия 3: логирование через probe (разработка)
use panic_probe as _; // Отправляет информацию о panic через отладочный зонд (с defmt)
// Стратегия 4: логирование через defmt, затем остановка
use defmt_panic as _; // Подробные сообщения о panic через ITM/RTT
// Стратегия 5: собственный обработчик (продакшн-прошивка)
use core::panic::PanicInfo;
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
// 1. Запрещаем прерывания, чтобы не нанести дополнительный ущерб
cortex_m::interrupt::disable();
// 2. Записываем информацию о panic в зарезервированную область RAM (переживает сброс)
// SAFETY: PANIC_LOG — зарезервированная область памяти, определённая в скрипте компоновщика.
unsafe {
let log = 0x2000_0000 as *mut [u8; 256];
// Записываем обрезанное сообщение о panic
use core::fmt::Write;
let mut writer = FixedWriter::new(&mut *log);
let _ = write!(writer, "{}", info);
}
// 3. Запускаем сброс по сторожевому таймеру (или мигаем светодиодом ошибки)
loop {
cortex_m::asm::wfi(); // Ожидание прерывания (малое потребление, пока остановлены)
}
}
}
Скрипты компоновщика и раскладка памяти
Разработчики прошивок на C пишут скрипты компоновщика, чтобы описать области FLASH/RAM. Встраиваемый
Rust использует ту же концепцию через memory.x:
/* memory.x — размещается в корне крейта, используется cortex-m-rt */
MEMORY
{
/* Подстройте под ваш MCU — здесь значения для STM32F401 */
FLASH : ORIGIN = 0x08000000, LENGTH = 512K
RAM : ORIGIN = 0x20000000, LENGTH = 96K
}
/* Необязательно: резервируем место для журнала panic (см. обработчик panic выше) */
_panic_log_start = ORIGIN(RAM);
_panic_log_size = 256;
# .cargo/config.toml — задаём цель и флаги компоновщика
[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip STM32F401RE" # прошивка и запуск через отладочный зонд
rustflags = [
"-C", "link-arg=-Tlink.x", # скрипт компоновщика cortex-m-rt
]
[build]
target = "thumbv7em-none-eabihf" # Cortex-M4F с аппаратным FPU
| Скрипт компоновщика C | Аналог в Rust |
|---|---|
MEMORY { FLASH ..., RAM ... } | memory.x в корне крейта |
__attribute__((section(".data"))) | #[link_section = ".data"] |
-T linker.ld в Makefile | -C link-arg=-Tlink.x в .cargo/config.toml |
__bss_start__, __bss_end__ | Обрабатывается cortex-m-rt автоматически |
Стартовый ассемблер (startup.s) | Макрос #[entry] из cortex-m-rt |
Написание драйверов на embedded-hal
Крейт embedded-hal определяет трейты для SPI, I2C, GPIO, UART и т. д. Драйверы,
написанные на основе этих трейтов, работают на любом MCU — это главная возможность Rust
для повторного использования во встраиваемых системах.
C и Rust: драйвер датчика температуры
// C — драйвер жёстко связан с HAL STM32
#include "stm32f4xx_hal.h"
float read_temperature(I2C_HandleTypeDef* hi2c, uint8_t addr) {
uint8_t buf[2];
HAL_I2C_Mem_Read(hi2c, addr << 1, 0x00, I2C_MEMADD_SIZE_8BIT,
buf, 2, HAL_MAX_DELAY);
int16_t raw = ((int16_t)buf[0] << 4) | (buf[1] >> 4);
return raw * 0.0625;
}
// Проблема: этот драйвер работает ТОЛЬКО с HAL STM32. Перенос на Nordic = переписывание.
#![allow(unused)]
fn main() {
// Rust — драйвер работает на ЛЮБОМ MCU, реализующем embedded-hal
use embedded_hal::i2c::I2c;
pub struct Tmp102<I2C> {
i2c: I2C,
address: u8,
}
impl<I2C: I2c> Tmp102<I2C> {
pub fn new(i2c: I2C, address: u8) -> Self {
Self { i2c, address }
}
pub fn read_temperature(&mut self) -> Result<f32, I2C::Error> {
let mut buf = [0u8; 2];
self.i2c.write_read(self.address, &[0x00], &mut buf)?;
let raw = ((buf[0] as i16) << 4) | ((buf[1] as i16) >> 4);
Ok(raw as f32 * 0.0625)
}
}
// Работает на STM32, Nordic nRF, ESP32, RP2040 — на любом чипе с реализацией I2C из embedded-hal
}
graph TD
subgraph "Архитектура драйвера в C"
CD["Драйвер датчика температуры"]
CD --> STM["HAL STM32"]
CD -.->|"Перенос = ПЕРЕПИСАТЬ"| NRF["HAL Nordic"]
CD -.->|"Перенос = ПЕРЕПИСАТЬ"| ESP["ESP-IDF"]
end
subgraph "Архитектура embedded-hal в Rust"
RD["Драйвер датчика температуры<br/>impl<I2C: I2c>"]
RD --> EHAL["Трейт embedded-hal::I2c"]
EHAL --> STM2["stm32f4xx-hal"]
EHAL --> NRF2["nrf52-hal"]
EHAL --> ESP2["esp-hal"]
EHAL --> RP2["rp2040-hal"]
NOTE["Пишем драйвер ОДИН раз,<br/>работает на ВСЕХ чипах"]
end
style CD fill:#ffa07a,color:#000
style RD fill:#91e5a3,color:#000
style EHAL fill:#91e5a3,color:#000
style NOTE fill:#91e5a3,color:#000
Настройка глобального аллокатора
Крейт alloc даёт вам Vec, String, Box, но нужно указать Rust,
откуда берётся память кучи. Это эквивалент реализации malloc()
для вашей платформы:
#![no_std]
extern crate alloc;
use alloc::vec::Vec;
use alloc::string::String;
use embedded_alloc::LlffHeap as Heap;
#[global_allocator]
static HEAP: Heap = Heap::empty();
#[cortex_m_rt::entry]
fn main() -> ! {
// Инициализируем аллокатор на области памяти
// (обычно часть RAM, не используемая стеком или статическими данными)
{
const HEAP_SIZE: usize = 4096;
static mut HEAP_MEM: [u8; HEAP_SIZE] = [0; HEAP_SIZE];
// SAFETY: HEAP_MEM здесь используется только при инициализации, до любого выделения памяти.
unsafe { HEAP.init(HEAP_MEM.as_ptr() as usize, HEAP_SIZE) }
}
// Теперь можно использовать типы кучи!
let mut log_buffer: Vec<u8> = Vec::with_capacity(256);
let name: String = String::from("sensor_01");
// ...
loop {}
}
| Настройка кучи в C | Аналог в Rust |
|---|---|
_sbrk() / собственный malloc() | #[global_allocator] + Heap::init() |
configTOTAL_HEAP_SIZE (FreeRTOS) | Константа HEAP_SIZE |
pvPortMalloc() | alloc::vec::Vec::new() — автоматически |
| Исчерпание кучи → неопределённое поведение | alloc_error_handler → контролируемый panic |
Смешанные рабочие пространства no_std + std
В реальных проектах (например, в большом рабочем пространстве Rust) часто встречаются:
- Библиотечные крейты
no_stdдля переносимой между платформами логики - Бинарные крейты
stdдля прикладного уровня под Linux
workspace_root/
├── Cargo.toml # [workspace] members = [...]
├── protocol/ # no_std — протокол обмена, разбор
│ ├── Cargo.toml # без default-features, без std
│ └── src/lib.rs # #![no_std]
├── driver/ # no_std — абстракция оборудования
│ ├── Cargo.toml
│ └── src/lib.rs # #![no_std], использует трейты embedded-hal
├── firmware/ # no_std — бинарный файл MCU
│ ├── Cargo.toml # зависит от protocol, driver
│ └── src/main.rs # #![no_std] #![no_main]
└── host_tool/ # std — CLI-инструмент под Linux
├── Cargo.toml # зависит от protocol (тот же крейт!)
└── src/main.rs # Использует std::fs, std::net и т. д.
Ключевой паттерн: крейт protocol использует #![no_std], поэтому он компилируется и для
прошивки MCU, и для инструмента на Linux. Общий код, нулевое дублирование.
# protocol/Cargo.toml
[package]
name = "protocol"
[features]
default = []
std = [] # Необязательно: включает возможности, специфичные для std, при сборке для хоста
[dependencies]
serde = { version = "1", default-features = false, features = ["derive"] }
# Примечание: default-features = false убирает зависимость serde от std
#![allow(unused)]
fn main() {
// protocol/src/lib.rs
#![cfg_attr(not(feature = "std"), no_std)]
#[cfg(feature = "std")]
extern crate std;
extern crate alloc;
use alloc::vec::Vec;
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct DiagPacket {
pub sensor_id: u16,
pub value: i32,
pub fault_code: u16,
}
// Эта функция работает и в контексте no_std, и в контексте std
pub fn parse_packet(data: &[u8]) -> Result<DiagPacket, &'static str> {
if data.len() < 8 {
return Err("packet too short");
}
Ok(DiagPacket {
sensor_id: u16::from_le_bytes([data[0], data[1]]),
value: i32::from_le_bytes([data[2], data[3], data[4], data[5]]),
fault_code: u16::from_le_bytes([data[6], data[7]]),
})
}
}
Упражнение: драйвер слоя абстракции оборудования
Напишите драйвер no_std для гипотетического контроллера светодиодов, который обменивается данными по SPI.
Драйвер должен быть обобщённым по любой реализации SPI на основе embedded-hal.
Требования:
- Определите структуру
LedController<SPI> - Реализуйте
new(),set_brightness(led: u8, brightness: u8)иall_off() - Протокол SPI: отправлять
[led_index, brightness_value]как транзакцию из 2 байт - Напишите тесты с использованием мок-реализации SPI
#![allow(unused)]
fn main() {
// Стартовый код
#![no_std]
use embedded_hal::spi::SpiDevice;
pub struct LedController<SPI> {
spi: SPI,
num_leds: u8,
}
// TODO: Реализуйте new(), set_brightness(), all_off()
// TODO: Создайте MockSpi для тестирования
}
Решение (нажмите, чтобы развернуть)
#![allow(unused)]
#![no_std]
fn main() {
use embedded_hal::spi::SpiDevice;
pub struct LedController<SPI> {
spi: SPI,
num_leds: u8,
}
impl<SPI: SpiDevice> LedController<SPI> {
pub fn new(spi: SPI, num_leds: u8) -> Self {
Self { spi, num_leds }
}
pub fn set_brightness(&mut self, led: u8, brightness: u8) -> Result<(), SPI::Error> {
if led >= self.num_leds {
return Ok(()); // Молча игнорируем светодиоды вне диапазона
}
self.spi.write(&[led, brightness])
}
pub fn all_off(&mut self) -> Result<(), SPI::Error> {
for led in 0..self.num_leds {
self.spi.write(&[led, 0])?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
// Мок SPI, который записывает все транзакции
struct MockSpi {
transactions: Vec<Vec<u8>>,
}
// Минимальный тип ошибки для мока
#[derive(Debug)]
struct MockError;
impl embedded_hal::spi::Error for MockError {
fn kind(&self) -> embedded_hal::spi::ErrorKind {
embedded_hal::spi::ErrorKind::Other
}
}
impl embedded_hal::spi::ErrorType for MockSpi {
type Error = MockError;
}
impl SpiDevice for MockSpi {
fn write(&mut self, buf: &[u8]) -> Result<(), Self::Error> {
self.transactions.push(buf.to_vec());
Ok(())
}
fn read(&mut self, _buf: &mut [u8]) -> Result<(), Self::Error> { Ok(()) }
fn transfer(&mut self, _r: &mut [u8], _w: &[u8]) -> Result<(), Self::Error> { Ok(()) }
fn transfer_in_place(&mut self, _buf: &mut [u8]) -> Result<(), Self::Error> { Ok(()) }
fn transaction(&mut self, _ops: &mut [embedded_hal::spi::Operation<'_, u8>]) -> Result<(), Self::Error> { Ok(()) }
}
#[test]
fn test_set_brightness() {
let mock = MockSpi { transactions: vec![] };
let mut ctrl = LedController::new(mock, 4);
ctrl.set_brightness(2, 128).unwrap();
assert_eq!(ctrl.spi.transactions, vec![vec![2, 128]]);
}
#[test]
fn test_all_off() {
let mock = MockSpi { transactions: vec![] };
let mut ctrl = LedController::new(mock, 3);
ctrl.all_off().unwrap();
assert_eq!(ctrl.spi.transactions, vec![
vec![0, 0], vec![1, 0], vec![2, 0],
]);
}
#[test]
fn test_out_of_range_led() {
let mock = MockSpi { transactions: vec![] };
let mut ctrl = LedController::new(mock, 2);
ctrl.set_brightness(5, 255).unwrap(); // Вне диапазона — игнорируется
assert!(ctrl.spi.transactions.is_empty());
}
}
}
Отладка встраиваемого Rust — probe-rs, defmt и VS Code
Разработчики прошивок на C обычно отлаживают с помощью OpenOCD + GDB или фирменных IDE (Keil, IAR, Segger Ozone). Экосистема встраиваемого Rust сошлась на probe-rs как едином интерфейсе отладочного зонда, заменив связку OpenOCD + GDB одним инструментом на Rust.
probe-rs — универсальный инструмент отладочного зонда
probe-rs заменяет связку OpenOCD + GDB. Из коробки поддерживаются CMSIS-DAP,
ST-Link, J-Link и другие отладочные зонды:
# Установка probe-rs (включает cargo-flash и cargo-embed)
cargo install probe-rs-tools
# Прошивка и запуск вашей прошивки
cargo flash --chip STM32F401RE --release
# Прошивка, запуск и открытие консоли RTT (Real-Time Transfer)
cargo embed --chip STM32F401RE
probe-rs против OpenOCD + GDB:
| Аспект | OpenOCD + GDB | probe-rs |
|---|---|---|
| Установка | 2 отдельных пакета + скрипты | cargo install probe-rs-tools |
| Настройка | Файлы .cfg для каждой платы/зонда | Флаг --chip или Embed.toml |
| Вывод в консоль | Semihosting (очень медленно) | RTT (~10× быстрее) |
| Фреймворк логирования | printf | defmt (структурированный, без накладных расходов) |
| Алгоритм прошивки | XML-пакеты | Встроен для более чем 1000 чипов |
| Поддержка GDB | Нативная | Адаптер probe-rs gdb |
Embed.toml — конфигурация проекта
Вместо возни с файлами .cfg и .gdbinit probe-rs использует один конфигурационный файл:
# Embed.toml — размещается в корне проекта
[default.general]
chip = "STM32F401RETx"
[default.rtt]
enabled = true # Включить консоль Real-Time Transfer
channels = [
{ up = 0, mode = "BlockIfFull", name = "Terminal" },
]
[default.flashing]
enabled = true # Прошивать перед запуском
restore_unwritten_bytes = false
[default.reset]
halt_afterwards = false # Запускать после прошивки и сброса
[default.gdb]
enabled = false # Установите true, чтобы открыть GDB-сервер на :1337
gdb_connection_string = "127.0.0.1:1337"
# С Embed.toml достаточно запустить:
cargo embed # Прошивка + консоль RTT — без флагов
cargo embed --release # Релизная сборка
defmt — отложенное форматирование для логирования на встраиваемых системах
defmt (deferred formatting, отложенное форматирование) заменяет отладку через printf. Строки формата
хранятся в ELF-файле, а не во flash — поэтому вызов логирования на цели отправляет
только индекс и байты аргументов. Благодаря этому логирование работает в 10–100 раз быстрее, чем printf,
и занимает малую долю flash-памяти:
#![no_std]
#![no_main]
use defmt::{info, warn, error, debug, trace};
use defmt_rtt as _; // Транспорт RTT — связывает вывод defmt с probe-rs
#[cortex_m_rt::entry]
fn main() -> ! {
info!("Boot complete, firmware v{}", env!("CARGO_PKG_VERSION"));
let sensor_id: u16 = 0x4A;
let temperature: f32 = 23.5;
// Строки формата остаются в ELF, а не во flash — практически без накладных расходов
debug!("Sensor {:#06X}: {:.1}°C", sensor_id, temperature);
if temperature > 80.0 {
warn!("Overtemp on sensor {:#06X}: {:.1}°C", sensor_id, temperature);
}
loop {
cortex_m::asm::wfi(); // Ожидание прерывания
}
}
// Собственные типы — реализуйте defmt::Format вместо Debug
#[derive(defmt::Format)]
struct SensorReading {
id: u16,
value: i32,
status: SensorStatus,
}
#[derive(defmt::Format)]
enum SensorStatus {
Ok,
Warning,
Fault(u8),
}
// Использование:
// info!("Reading: {:?}", reading); // <-- использует defmt::Format, а НЕ std Debug
defmt против printf и log:
| Характеристика | C printf (semihosting) | Крейт Rust log | defmt |
|---|---|---|---|
| Скорость | ~100 мс на вызов | Н/Д (нужен std) | ~1 мкс на вызов |
| Использование flash | Полные строки формата | Полные строки формата | Только индекс (байты) |
| Транспорт | Semihosting (останавливает CPU) | Serial/UART | RTT (неблокирующий) |
| Структурированный вывод | Нет | Только текст | Типизированный, бинарно закодированный |
no_std | Через semihosting | Только фасад (бэкенды требуют std) | ✅ Нативно |
| Уровни фильтрации | Ручной #ifdef | RUST_LOG=debug | defmt::println + features |
Конфигурация отладки в VS Code
С расширением VS Code probe-rs вы получаете полноценную графическую отладку —
точки останова, просмотр переменных, стек вызовов и просмотр регистров:
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "probe-rs-debug",
"request": "launch",
"name": "Flash & Debug (probe-rs)",
"chip": "STM32F401RETx",
"coreConfigs": [
{
"programBinary": "target/thumbv7em-none-eabihf/debug/${workspaceFolderBasename}",
"rttEnabled": true,
"rttChannelFormats": [
{
"channelNumber": 0,
"dataFormat": "Defmt",
"showTimestamps": true
}
]
}
],
"connectUnderReset": true,
"speed": 4000
}
]
}
Установите расширение:
#![allow(unused)]
fn main() {
ext install probe-rs.probe-rs-debugger
}
Рабочий процесс отладки C и отладка встраиваемого Rust
graph LR
subgraph "Рабочий процесс C (традиционный)"
C1["Пишем код"] --> C2["make flash"]
C2 --> C3["openocd -f board.cfg"]
C3 --> C4["arm-none-eabi-gdb<br/>target remote :3333"]
C4 --> C5["printf через semihosting<br/>(~100 мс на вызов, останавливает CPU)"]
end
subgraph "Рабочий процесс Rust (probe-rs)"
R1["Пишем код"] --> R2["cargo embed"]
R2 --> R3["Прошивка + консоль RTT<br/>одной командой"]
R3 --> R4["Поток логов defmt<br/>в реальном времени (~1 мкс)"]
R2 -.->|"Или"| R5["VS Code F5<br/>Полноценный графический отладчик"]
end
style C5 fill:#ffa07a,color:#000
style R3 fill:#91e5a3,color:#000
style R4 fill:#91e5a3,color:#000
style R5 fill:#91e5a3,color:#000
| Действие отладки в C | Аналог в Rust |
|---|---|
openocd -f board/st_nucleo_f4.cfg | probe-rs info (автоопределение зонда и чипа) |
arm-none-eabi-gdb -x .gdbinit | probe-rs gdb --chip STM32F401RE |
target remote :3333 | GDB подключается к localhost:1337 |
monitor reset halt | probe-rs reset --chip ... |
load firmware.elf | cargo flash --chip ... |
printf("debug: %d\n", val) (семихостинг) | defmt::info!("debug: {}", val) (RTT) |
| Графический отладчик Keil/IAR | VS Code + расширение probe-rs-debugger |
| Segger SystemView | defmt + просмотрщик RTT из probe-rs |
Перекрёстная ссылка: о продвинутых паттернах unsafe, используемых в драйверах для встраиваемых систем (проекции Pin, собственные аллокаторы арены и slab), см. сопутствующее руководство Паттерны Rust, разделы «Проекции pin: структурное закрепление» и «Пользовательские аллокаторы: паттерны арен и slab».
Обзор кейсов: перевод с C++ на Rust
Что вы узнаете: уроки из реального перевода примерно 100 тысяч строк C++ примерно на 90 тысяч строк Rust в составе около 20 крейтов. Пять ключевых паттернов преобразования и архитектурные решения, стоящие за ними.
- Мы перевели большую диагностическую систему на C++ (около 100 тысяч строк C++) на реализацию на Rust (около 20 крейтов Rust, около 90 тысяч строк)
- В этом разделе показаны реальные паттерны, которые использовались, — не игрушечные примеры, а настоящий продакшн-код
- Пять ключевых преобразований:
| № | Паттерн C++ | Паттерн Rust | Эффект |
|---|---|---|---|
| 1 | Иерархия классов + dynamic_cast | Диспетчеризация через перечисления + match | ~400 → 0 вызовов dynamic_cast |
| 2 | Дерево на shared_ptr / enable_shared_from_this | Арена + связи через индексы | Циклов ссылок нет |
| 3 | Сырой указатель Framework* в каждом модуле | DiagContext<'a> с заимствованием по времени жизни | Корректность ссылок проверяется на этапе компиляции |
| 4 | «Божественный объект» (god object) | Компонуемые структуры состояния | Тестируемость, модульность |
| 5 | vector<unique_ptr<Base>> повсюду | Трейт-объекты только там, где нужно (~25 мест) | По умолчанию — статическая диспетчеризация |
Метрики до и после
| Метрика | C++ (исходный) | Rust (переписанный) |
|---|---|---|
dynamic_cast / понижающие приведения типов | ~400 | 0 |
Методы virtual / override | ~900 | ~25 (Box<dyn Trait>) |
Сырые выделения new | ~200 | 0 (все типы владеющие) |
shared_ptr / подсчёт ссылок | ~10 (библиотека топологии) | 0 (Arc только на границе FFI) |
Определения enum class | ~60 | ~190 pub enum |
| Выражения сопоставления с образцом | Н/Д | ~750 match |
| «Божественные объекты» (>5 тыс. строк) | 2 | 0 |
Кейс 1: иерархия наследования → диспетчеризация через перечисления
Паттерн C++: иерархия классов событий
// Исходный C++: каждый тип события GPU — это класс, наследующий GpuEventBase
class GpuEventBase {
public:
virtual ~GpuEventBase() = default;
virtual void Process(DiagFramework* fw) = 0;
uint16_t m_recordId;
uint8_t m_sensorType;
// ... общие поля
};
class GpuPcieDegradeEvent : public GpuEventBase {
public:
void Process(DiagFramework* fw) override;
uint8_t m_linkSpeed;
uint8_t m_linkWidth;
};
class GpuPcieFatalEvent : public GpuEventBase { /* ... */ };
class GpuBootEvent : public GpuEventBase { /* ... */ };
// ... 10+ классов событий, наследующих GpuEventBase
// Обработка требует dynamic_cast:
void ProcessEvents(std::vector<std::unique_ptr<GpuEventBase>>& events,
DiagFramework* fw) {
for (auto& event : events) {
if (auto* degrade = dynamic_cast<GpuPcieDegradeEvent*>(event.get())) {
// обработка degrade...
} else if (auto* fatal = dynamic_cast<GpuPcieFatalEvent*>(event.get())) {
// обработка fatal...
}
// ... ещё 10 веток
}
}
Решение на Rust: диспетчеризация через перечисления
#![allow(unused)]
fn main() {
// Пример: types.rs — без наследования, без vtable, без dynamic_cast
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum GpuEventKind {
PcieDegrade,
PcieFatal,
PcieUncorr,
Boot,
BaseboardState,
EccError,
OverTemp,
PowerRail,
ErotStatus,
Unknown,
}
}
#![allow(unused)]
fn main() {
// Пример: manager.rs — отдельные типизированные Vec, понижающее приведение не нужно
pub struct GpuEventManager {
sku: SkuVariant,
degrade_events: Vec<GpuPcieDegradeEvent>, // Конкретный тип, а не Box<dyn>
fatal_events: Vec<GpuPcieFatalEvent>,
uncorr_events: Vec<GpuPcieUncorrEvent>,
boot_events: Vec<GpuBootEvent>,
baseboard_events: Vec<GpuBaseboardEvent>,
ecc_events: Vec<GpuEccEvent>,
// ... у каждого типа события свой Vec
}
// Методы доступа возвращают типизированные срезы — никакой двусмысленности
impl GpuEventManager {
pub fn degrade_events(&self) -> &[GpuPcieDegradeEvent] {
&self.degrade_events
}
pub fn fatal_events(&self) -> &[GpuPcieFatalEvent] {
&self.fatal_events
}
}
}
Почему не Vec<Box<dyn GpuEvent>>?
- Неверный подход (буквальный перевод): сложить все события в одну разнородную коллекцию, а затем приводить типы — именно так C++ делает с
vector<unique_ptr<Base>> - Верный подход: отдельные типизированные Vec устраняют всё понижающее приведение типов. Каждый потребитель запрашивает ровно тот тип события, который ему нужен
- Производительность: отдельные Vec дают лучшую локальность кэша (все события degrade лежат в памяти непрерывно)
Кейс 2: дерево на shared_ptr → паттерн арены и индексов
Паттерн C++: дерево с подсчётом ссылок
// Библиотека топологии на C++: PcieDevice использует enable_shared_from_this,
// потому что и родительский, и дочерние узлы должны ссылаться друг на друга
class PcieDevice : public std::enable_shared_from_this<PcieDevice> {
public:
std::shared_ptr<PcieDevice> m_upstream;
std::vector<std::shared_ptr<PcieDevice>> m_downstream;
// ... данные устройства
void AddChild(std::shared_ptr<PcieDevice> child) {
child->m_upstream = shared_from_this(); // Цикл родитель ↔ потомок!
m_downstream.push_back(child);
}
};
// Проблема: связи родитель→потомок и потомок→родитель создают циклы ссылок
// Чтобы разорвать циклы, нужен weak_ptr, но про него легко забыть
Решение на Rust: арена со связями через индексы
#![allow(unused)]
fn main() {
// Пример: components.rs — плоский Vec владеет всеми устройствами
pub struct PcieDevice {
pub base: PcieDeviceBase,
pub kind: PcieDeviceKind,
// Связи дерева через индексы — без подсчёта ссылок и без циклов
pub upstream_idx: Option<usize>, // Индекс в Vec-арене
pub downstream_idxs: Vec<usize>, // Индексы в Vec-арене
}
// «Арена» — это просто Vec<PcieDevice>, которым владеет дерево:
pub struct DeviceTree {
devices: Vec<PcieDevice>, // Плоское владение — один Vec владеет всем
}
impl DeviceTree {
pub fn parent(&self, device_idx: usize) -> Option<&PcieDevice> {
self.devices[device_idx].upstream_idx
.map(|idx| &self.devices[idx])
}
pub fn children(&self, device_idx: usize) -> Vec<&PcieDevice> {
self.devices[device_idx].downstream_idxs
.iter()
.map(|&idx| &self.devices[idx])
.collect()
}
}
}
Главная мысль
- Никаких
shared_ptr,weak_ptrиenable_shared_from_this - Циклов ссылок быть не может — индексы — это просто значения
usize - Лучшая производительность кэша — все устройства лежат в памяти непрерывно
- Проще рассуждать — один владелец (Vec), много «наблюдателей» (индексы)
graph LR
subgraph "Дерево на shared_ptr в C++"
A1["shared_ptr<Device>"] -->|"shared_ptr"| B1["shared_ptr<Device>"]
B1 -->|"shared_ptr (родитель)"| A1
A1 -->|"shared_ptr"| C1["shared_ptr<Device>"]
C1 -->|"shared_ptr (родитель)"| A1
style A1 fill:#ff6b6b,color:#000
style B1 fill:#ffa07a,color:#000
style C1 fill:#ffa07a,color:#000
end
subgraph "Арена и индексы в Rust"
V["Vec<PcieDevice>"]
V --> D0["[0] Корень<br/>upstream: None<br/>down: [1,2]"]
V --> D1["[1] Потомок<br/>upstream: Some(0)<br/>down: []"]
V --> D2["[2] Потомок<br/>upstream: Some(0)<br/>down: []"]
style V fill:#51cf66,color:#000
style D0 fill:#91e5a3,color:#000
style D1 fill:#91e5a3,color:#000
style D2 fill:#91e5a3,color:#000
end
Кейс 3: коммуникация с фреймворком → заимствование по времени жизни
Что вы узнаете: как перевести паттерны взаимодействия с фреймворком через сырые указатели из C++ на систему заимствований Rust, основанную на временах жизни, устраняя риск висячих указателей и сохраняя абстракции без накладных расходов.
Паттерн C++: сырой указатель на фреймворк
// Исходный C++: каждый диагностический модуль хранит сырой указатель на фреймворк
class DiagBase {
protected:
DiagFramework* m_pFramework; // Сырой указатель — кто им владеет?
public:
DiagBase(DiagFramework* fw) : m_pFramework(fw) {}
void LogEvent(uint32_t code, const std::string& msg) {
m_pFramework->GetEventLog()->Record(code, msg); // Надеемся, что он ещё жив!
}
};
// Проблема: m_pFramework — сырой указатель без гарантии времени жизни
// Если фреймворк уничтожен, пока модули ещё на него ссылаются → неопределённое поведение
Решение на Rust: DiagContext с заимствованием по времени жизни
#![allow(unused)]
fn main() {
// Пример: module.rs — заимствуем, а не храним
/// Контекст, передаваемый диагностическим модулям во время выполнения.
/// Время жизни 'a гарантирует, что фреймворк переживёт контекст.
pub struct DiagContext<'a> {
pub der_log: &'a mut EventLogManager,
pub config: &'a ModuleConfig,
pub framework_opts: &'a HashMap<String, String>,
}
/// Модули получают контекст как параметр — никогда не храним указатели на фреймворк
pub trait DiagModule {
fn id(&self) -> &str;
fn execute(&mut self, ctx: &mut DiagContext) -> DiagResult<()>;
fn pre_execute(&mut self, _ctx: &mut DiagContext) -> DiagResult<()> {
Ok(())
}
fn post_execute(&mut self, _ctx: &mut DiagContext) -> DiagResult<()> {
Ok(())
}
}
}
Главная мысль
- Модули C++ хранят указатель на фреймворк (опасность: что если фреймворк уничтожат первым?)
- Модули Rust получают контекст как параметр функции — проверка заимствований гарантирует, что фреймворк жив во время вызова
- Никаких сырых указателей, никакой неоднозначности времён жизни, никаких «надеемся, что он ещё жив»
Кейс 4: «божественный объект» → компонуемое состояние
Паттерн C++: монолитный класс фреймворка
// Исходный C++: фреймворк — божественный объект
class DiagFramework {
// Обработка ловушек монитора здоровья
std::vector<AlertTriggerInfo> m_alertTriggers;
std::vector<WarnTriggerInfo> m_warnTriggers;
bool m_healthMonHasBootTimeError;
uint32_t m_healthMonActionCounter;
// Диагностика GPU
std::map<uint32_t, GpuPcieInfo> m_gpuPcieMap;
bool m_isRecoveryContext;
bool m_healthcheckDetectedDevices;
// ... ещё 30+ полей, связанных с GPU
// Дерево PCIe
std::shared_ptr<CPcieTreeLinux> m_pPcieTree;
// Логирование событий
CEventLogMgr* m_pEventLogMgr;
// ... несколько других методов
void HandleGpuEvents();
void HandleNicEvents();
void RunGpuDiag();
// Всё зависит от всего
};
Решение на Rust: компонуемые структуры состояния
#![allow(unused)]
fn main() {
// Пример: main.rs — состояние разбито на сфокусированные структуры
#[derive(Default)]
struct HealthMonitorState {
alert_triggers: Vec<AlertTriggerInfo>,
warn_triggers: Vec<WarnTriggerInfo>,
health_monitor_action_counter: u32,
health_monitor_has_boot_time_error: bool,
// Только поля, связанные с монитором здоровья
}
#[derive(Default)]
struct GpuDiagState {
gpu_pcie_map: HashMap<u32, GpuPcieInfo>,
is_recovery_context: bool,
healthcheck_detected_devices: bool,
// Только поля, связанные с GPU
}
/// Фреймворк компонует эти состояния, а не владеет всем плоско
struct DiagFramework {
ctx: DiagContext, // Контекст выполнения
args: Args, // Аргументы командной строки
pcie_tree: Option<DeviceTree>, // shared_ptr не нужен
event_log_mgr: EventLogManager, // Владеющий, а не сырой указатель
fc_manager: FcManager, // Управление кодами неисправностей
health: HealthMonitorState, // Состояние монитора здоровья — отдельная структура
gpu: GpuDiagState, // Состояние GPU — отдельная структура
}
}
Главная мысль
- Тестируемость: каждую структуру состояния можно тестировать независимо
- Читаемость:
self.health.alert_triggersпротивm_alertTriggers— владение очевидно - Рефакторинг без страха: изменение
GpuDiagStateне может случайно повлиять на обработку монитора здоровья - Никакой «каши из методов»: функции, которым нужно только состояние монитора здоровья, принимают
&mut HealthMonitorState, а не весь фреймворк
Кейс 5: трейт-объекты — когда они действительно уместны
- Не всё должно быть перечислением! Система плагинов диагностических модулей — подлинный случай для трейт-объектов
- Почему? Потому что диагностические модули открыты для расширения — новые модули можно добавлять, не меняя фреймворк
#![allow(unused)]
fn main() {
// Пример: framework.rs — Vec<Box<dyn DiagModule>> здесь уместен
pub struct DiagFramework {
modules: Vec<Box<dyn DiagModule>>, // Полиморфизм времени выполнения
pre_diag_modules: Vec<Box<dyn DiagModule>>,
event_log_mgr: EventLogManager,
// ...
}
impl DiagFramework {
/// Регистрация диагностического модуля — любой тип, реализующий DiagModule
pub fn register_module(&mut self, module: Box<dyn DiagModule>) {
info!("Registering module: {}", module.id());
self.modules.push(module);
}
}
}
Когда использовать каждый паттерн
| Сценарий | Паттерн | Почему |
|---|---|---|
| Фиксированный набор вариантов, известный на этапе компиляции | enum + match | Исчерпывающая проверка, без vtable |
| Типы аппаратных событий (Degrade, Fatal, Boot, …) | enum GpuEventKind | Все варианты известны, важна производительность |
| Типы устройств PCIe (GPU, NIC, Switch, …) | enum PcieDeviceKind | Фиксированный набор, у каждого варианта свои данные |
| Система плагинов/модулей (открыта для расширения) | Box<dyn Trait> | Новые модули добавляются без изменения фреймворка |
| Мокирование в тестах | Box<dyn Trait> | Внедрение тестовых двойников |
Упражнение: подумайте, прежде чем переводить
Дан следующий код на C++:
class Shape { public: virtual double area() = 0; };
class Circle : public Shape { double r; double area() override { return 3.14*r*r; } };
class Rect : public Shape { double w, h; double area() override { return w*h; } };
std::vector<std::unique_ptr<Shape>> shapes;
Вопрос: должен ли перевод на Rust использовать enum Shape или Vec<Box<dyn Shape>>?
Решение (нажмите, чтобы развернуть)
Ответ: enum Shape — потому что набор фигур закрыт (известен на этапе компиляции). Box<dyn Shape> понадобился бы, только если бы пользователи могли добавлять новые типы фигур во время выполнения.
// Правильный перевод на Rust:
enum Shape {
Circle { r: f64 },
Rect { w: f64, h: f64 },
}
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle { r } => std::f64::consts::PI * r * r,
Shape::Rect { w, h } => w * h,
}
}
}
fn main() {
let shapes: Vec<Shape> = vec![
Shape::Circle { r: 5.0 },
Shape::Rect { w: 3.0, h: 4.0 },
];
for shape in &shapes {
println!("Area: {:.2}", shape.area());
}
}
// Вывод:
// Area: 78.54
// Area: 12.00
Метрики перевода и извлечённые уроки
Что мы узнали
- По умолчанию используйте диспетчеризацию через перечисления — в примерно 100 тысячах строк C++ действительно понадобилось лишь около 25 использований
Box<dyn Trait>(системы плагинов, моки для тестов). Остальные примерно 900 виртуальных методов стали перечислениями сmatch - Паттерн арены устраняет циклы ссылок —
shared_ptrиenable_shared_from_this— признаки неясного владения. Сначала подумайте, кто владеет данными - Передавайте контекст, а не храните указатели —
DiagContext<'a>с ограниченным временем жизни безопаснее и понятнее, чем хранениеFramework*в каждом модуле - Разбивайте божественные объекты — если в структуре 30+ полей, то, скорее всего, это 3–4 структуры в одном плаще
- Компилятор — ваш напарник по парному программированию — около 400 вызовов
dynamic_castозначали около 400 потенциальных сбоев во время выполнения. Отсутствие аналоговdynamic_castв Rust означает отсутствие ошибок типов во время выполнения
Самые трудные места
- Аннотации времён жизни: правильно расставить заимствования непросто, если вы привыкли к сырым указателям, — но когда код компилируется, он корректен
- Борьба с проверкой заимствований: желание иметь
&mut selfв двух местах одновременно. Решение: разбить состояние на отдельные структуры - Противостояние буквальному переводу: соблазн писать
Vec<Box<dyn Base>>повсюду. Спросите себя: «Закрыт ли этот набор вариантов?» → Если да, используйте enum
Рекомендации для команд C++
- Начните с небольшого, самодостаточного модуля (а не с божественного объекта)
- Сначала переводите структуры данных, потом поведение
- Пусть компилятор вас направляет — его сообщения об ошибках отличны
- Выбирайте
enumраньше, чемdyn Trait - Используйте Rust playground, чтобы опробовать паттерны перед интеграцией
Сводка лучших практик Rust
Что вы узнаете: практические рекомендации по написанию идиоматичного Rust — организация кода, соглашения об именовании, паттерны обработки ошибок и документирование. Краткая справочная глава, к которой вы будете возвращаться часто.
Организация кода
- Предпочитайте небольшие функции: их легко тестировать и понимать
- Используйте описательные имена:
calculate_total_price()вместоcalc() - Группируйте связанную функциональность: используйте модули и отдельные файлы
- Пишите документацию: используйте
///для публичных API
Обработка ошибок
- Избегайте
unwrap(), если операция не может завершиться неудачей: используйте его, только когда на 100% уверены, что panic не будет
#![allow(unused)]
fn main() {
// Плохо: может вызвать panic
let value = some_option.unwrap();
// Хорошо: обрабатываем случай None
let value = some_option.unwrap_or(default_value);
let value = some_option.unwrap_or_else(|| expensive_computation());
let value = some_option.unwrap_or_default(); // Использует трейт Default
// Для Result<T, E>
let value = some_result.unwrap_or(fallback_value);
let value = some_result.unwrap_or_else(|err| {
eprintln!("Error occurred: {err}");
default_value
});
}
- Используйте
expect()с понятными сообщениями: если unwrap оправдан, объясните почему
#![allow(unused)]
fn main() {
let config = std::env::var("CONFIG_PATH")
.expect("CONFIG_PATH environment variable must be set");
}
- Возвращайте
Result<T, E>из операций, которые могут завершиться ошибкой: пусть вызывающий код решает, как обрабатывать ошибки - Используйте
thiserrorдля собственных типов ошибок: это удобнее ручной реализации
#![allow(unused)]
fn main() {
use thiserror::Error;
#[derive(Error, Debug)]
pub enum MyError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Parse error: {message}")]
Parse { message: String },
#[error("Value {value} is out of range")]
OutOfRange { value: i32 },
}
}
- Цепочка ошибок через оператор
?: передавайте ошибки вверх по стеку вызовов - Предпочитайте
thiserrorпередanyhow: соглашение нашей команды — определять явные перечисления ошибок через#[derive(thiserror::Error)], чтобы вызывающий код мог сопоставлять конкретные варианты.anyhow::Errorудобен для быстрых прототипов, но стирает тип ошибки, из-за чего вызывающему коду сложнее обрабатывать конкретные сбои. Используйтеthiserrorдля библиотечного и продакшн-кода;anyhowоставьте для одноразовых скриптов или бинарников верхнего уровня, где нужно только вывести ошибку. - Когда
unwrap()допустим:- Модульные тесты:
assert_eq!(result.unwrap(), expected) - Прототипирование: быстрый и грязный код, который вы потом замените
- Операции, которые не могут завершиться ошибкой: когда вы можете доказать, что сбоя не будет
- Модульные тесты:
#![allow(unused)]
fn main() {
let numbers = vec![1, 2, 3];
let first = numbers.get(0).unwrap(); // Безопасно: мы только что создали вектор с элементами
// Лучше: используем expect() с объяснением
let first = numbers.get(0).expect("numbers vec is non-empty by construction");
}
- Быстро падайте (fail fast): проверяйте предусловия в начале и сразу возвращайте ошибки
Управление памятью
- Предпочитайте заимствование клонированию: используйте
&Tвместо клонирования, когда это возможно - Используйте
Rc<T>умеренно: только когда нужно совместное владение - Ограничивайте времена жизни: используйте области видимости
{}, чтобы контролировать, когда значения уничтожаются - Избегайте
RefCell<T>в публичных API: внутреннюю изменяемость держите внутри
Производительность
- Профилируйте перед оптимизацией: используйте
cargo benchи инструменты профилирования - Предпочитайте итераторы циклам: они читаемее и часто быстрее
- Используйте
&strвместоString: когда не нужно владение - Рассмотрите
Box<T>для больших объектов на стеке: при необходимости переносите их в кучу
Трейты, которые стоит реализовать
Базовые трейты, о которых стоит подумать для каждого типа
Создавая собственные типы, рассмотрите реализацию этих базовых трейтов, чтобы ваши типы ощущались в Rust «родными»:
Debug и Display
#![allow(unused)]
fn main() {
use std::fmt;
#[derive(Debug)] // Автоматическая реализация для отладки
struct Person {
name: String,
age: u32,
}
// Ручная реализация Display для вывода, который видит пользователь
impl fmt::Display for Person {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} (age {})", self.name, self.age)
}
}
// Использование:
let person = Person { name: "Alice".to_string(), age: 30 };
println!("{:?}", person); // Debug: Person { name: "Alice", age: 30 }
println!("{}", person); // Display: Alice (age 30)
}
Clone и Copy
#![allow(unused)]
fn main() {
// Copy: неявное дублирование для малых простых типов
#[derive(Debug, Clone, Copy)]
struct Point {
x: i32,
y: i32,
}
// Clone: явное дублирование для сложных типов
#[derive(Debug, Clone)]
struct Person {
name: String, // String не реализует Copy
age: u32,
}
let p1 = Point { x: 1, y: 2 };
let p2 = p1; // Copy (неявно)
let person1 = Person { name: "Bob".to_string(), age: 25 };
let person2 = person1.clone(); // Clone (явно)
}
PartialEq и Eq
#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq, Eq)]
struct UserId(u64);
#[derive(Debug, PartialEq)]
struct Temperature {
celsius: f64, // f64 не реализует Eq (из-за NaN)
}
let id1 = UserId(123);
let id2 = UserId(123);
assert_eq!(id1, id2); // Работает благодаря PartialEq
let temp1 = Temperature { celsius: 20.0 };
let temp2 = Temperature { celsius: 20.0 };
assert_eq!(temp1, temp2); // Работает с PartialEq
}
PartialOrd и Ord
#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
struct Priority(u8);
let high = Priority(1);
let low = Priority(10);
assert!(high < low); // Меньшие числа = выше приоритет
// Использование в коллекциях
let mut priorities = vec![Priority(5), Priority(1), Priority(8)];
priorities.sort(); // Работает, потому что Priority реализует Ord
}
Default
#![allow(unused)]
fn main() {
#[derive(Debug, Default)]
struct Config {
debug: bool, // false (по умолчанию)
max_connections: u32, // 0 (по умолчанию)
timeout: Option<u64>, // None (по умолчанию)
}
// Собственная реализация Default
impl Default for Config {
fn default() -> Self {
Config {
debug: false,
max_connections: 100, // Собственное значение по умолчанию
timeout: Some(30), // Собственное значение по умолчанию
}
}
}
let config = Config::default();
let config = Config { debug: true, ..Default::default() }; // Частичное переопределение
}
From и Into
#![allow(unused)]
fn main() {
struct UserId(u64);
struct UserName(String);
// Реализуем From, а Into получаем бесплатно
impl From<u64> for UserId {
fn from(id: u64) -> Self {
UserId(id)
}
}
impl From<String> for UserName {
fn from(name: String) -> Self {
UserName(name)
}
}
impl From<&str> for UserName {
fn from(name: &str) -> Self {
UserName(name.to_string())
}
}
// Использование:
let user_id: UserId = 123u64.into(); // Через Into
let user_id = UserId::from(123u64); // Через From
let username = UserName::from("alice"); // &str -> UserName
let username: UserName = "bob".into(); // Через Into
}
TryFrom и TryInto
#![allow(unused)]
fn main() {
use std::convert::TryFrom;
struct PositiveNumber(u32);
#[derive(Debug)]
struct NegativeNumberError;
impl TryFrom<i32> for PositiveNumber {
type Error = NegativeNumberError;
fn try_from(value: i32) -> Result<Self, Self::Error> {
if value >= 0 {
Ok(PositiveNumber(value as u32))
} else {
Err(NegativeNumberError)
}
}
}
// Использование:
let positive = PositiveNumber::try_from(42)?; // Ok(PositiveNumber(42))
let error = PositiveNumber::try_from(-5); // Err(NegativeNumberError)
}
Serde (для сериализации)
#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct User {
id: u64,
name: String,
email: String,
}
// Автоматическая сериализация и десериализация в JSON
let user = User {
id: 1,
name: "Alice".to_string(),
email: "alice@example.com".to_string(),
};
let json = serde_json::to_string(&user)?;
let deserialized: User = serde_json::from_str(&json)?;
}
Чек-лист реализации трейтов
Для любого нового типа рассмотрите этот чек-лист:
#![allow(unused)]
fn main() {
#[derive(
Debug, // [OK] Всегда реализуйте для отладки
Clone, // [OK] Если тип должен можно было дублировать
PartialEq, // [OK] Если тип должен можно было сравнивать
Eq, // [OK] Если сравнение рефлексивно и транзитивно
PartialOrd, // [OK] Если у типа есть порядок
Ord, // [OK] Если порядок полный
Hash, // [OK] Если тип будет ключом HashMap
Default, // [OK] Если есть разумное значение по умолчанию
)]
struct MyType {
// поля...
}
// Ручные реализации, которые стоит рассмотреть:
impl Display for MyType { /* представление для пользователя */ }
impl From<OtherType> for MyType { /* удобное преобразование */ }
impl TryFrom<FallibleType> for MyType { /* преобразование, которое может завершиться ошибкой */ }
}
Когда НЕ реализовывать трейты
- Не реализуйте Copy для типов с данными в куче:
String,Vec,HashMapи т. д. - Не реализуйте Eq, если значения могут быть NaN: типы, содержащие
f32/f64 - Не реализуйте Default, если нет разумного значения по умолчанию: файловые дескрипторы, сетевые соединения
- Не реализуйте Clone, если клонирование дорогое: большие структуры данных (вместо этого рассмотрите
Rc<T>)
Итоги: преимущества трейтов
| Трейт | Преимущество | Когда использовать |
|---|---|---|
Debug | println!("{:?}", value) | Всегда (кроме редких случаев) |
Display | println!("{}", value) | Типы для пользователя |
Clone | value.clone() | Когда явное дублирование имеет смысл |
Copy | Неявное дублирование | Малые простые типы |
PartialEq | Операторы == и != | Большинство типов |
Eq | Рефлексивное равенство | Когда равенство математически корректно |
PartialOrd | <, >, <=, >= | Типы с естественным порядком |
Ord | sort(), BinaryHeap | Когда порядок полный |
Hash | Ключи HashMap | Типы, используемые как ключи словаря |
Default | Default::default() | Типы с очевидными значениями по умолчанию |
From/Into | Удобные преобразования | Частые преобразования типов |
TryFrom/TryInto | Преобразования с возможной ошибкой | Преобразования, которые могут завершиться неудачей |
Избегание избыточного clone()
Избегание избыточного clone()
Что вы узнаете: почему
.clone()в Rust — это «запах кода», как перестроить владение, чтобы убрать лишние копии, и конкретные паттерны, которые указывают на проблему в дизайне владения.
- Приходя из C++,
.clone()кажется безопасным значением по умолчанию — «просто скопируй». Но избыточное клонирование скрывает проблемы владения и вредит производительности. - Эмпирическое правило: если вы клонируете, чтобы угодить проверке заимствований, скорее всего, нужно перестроить владение.
Когда clone() ошибочен
#![allow(unused)]
fn main() {
// ПЛОХО: клонируем String только для того, чтобы передать функции, которая лишь читает его
fn log_message(msg: String) { // Без нужды забирает владение
println!("[LOG] {}", msg);
}
let message = String::from("GPU test passed");
log_message(message.clone()); // Расточительно: выделяет новую String целиком
log_message(message); // Оригинал поглощён — клонирование было бессмысленным
}
#![allow(unused)]
fn main() {
// ХОРОШО: принимаем заимствование — ноль выделений
fn log_message(msg: &str) { // Заимствует, не владеет
println!("[LOG] {}", msg);
}
let message = String::from("GPU test passed");
log_message(&message); // Без клонирования, без выделения памяти
log_message(&message); // Можно вызвать снова — message не поглощена
}
Реальный пример: возврат &str вместо клонирования
#![allow(unused)]
fn main() {
// Пример: healthcheck.rs — возвращает заимствованное представление, без выделений
pub fn serial_or_unknown(&self) -> &str {
self.serial.as_deref().unwrap_or(UNKNOWN_VALUE)
}
pub fn model_or_unknown(&self) -> &str {
self.model.as_deref().unwrap_or(UNKNOWN_VALUE)
}
}
Аналог в C++ вернул бы const std::string& или std::string_view, но в C++ ни то, ни другое не проверяется по времени жизни. В Rust проверка заимствований гарантирует, что возвращаемый &str не переживёт self.
Реальный пример: статические срезы строк — вообще без кучи
#![allow(unused)]
fn main() {
// Пример: healthcheck.rs — таблицы строк, известные на этапе компиляции
const HBM_SCREEN_RECIPES: &[&str] = &[
"hbm_ds_ntd", "hbm_ds_ntd_gfx", "hbm_dt_ntd", "hbm_dt_ntd_gfx",
"hbm_burnin_8h", "hbm_burnin_24h",
];
}
В C++ это обычно был бы std::vector<std::string> (выделяется в куче при первом использовании). Тип &'static [&'static str] в Rust живёт в памяти только для чтения — без накладных расходов во время выполнения.
Когда clone() действительно уместен
| Ситуация | Почему клонирование допустимо | Пример |
|---|---|---|
Arc::clone() для потоков | Увеличивает счётчик ссылок (~1 нс), не копирует данные | let flag = stop_flag.clone(); |
| Перемещение данных в порождённый поток | Поток нуждается в собственной копии | let ctx = ctx.clone(); thread::spawn(move || { ... }) |
Извлечение из полей &self | Нельзя переместить значение из заимствования | self.name.clone(), когда возвращаем владеющий String |
Небольшие типы Copy в Option | .copied() понятнее, чем .clone() | opt.get(0).copied() для Option<&u32> → Option<u32> |
Реальный пример: Arc::clone для совместного использования потоками
#![allow(unused)]
fn main() {
// Пример: workload.rs — Arc::clone дёшев (увеличение счётчика ссылок)
let stop_flag = Arc::new(AtomicBool::new(false));
let stop_flag_clone = stop_flag.clone(); // ~1 нс, данные не копируются
let ctx_clone = ctx.clone(); // Клонируем контекст для перемещения в поток
let sensor_handle = thread::spawn(move || {
// ...использует stop_flag_clone и ctx_clone
});
}
Чек-лист: нужно ли клонировать?
- Могу ли я принять
&str/&TвместоString/T? → Заимствуйте, не клонируйте - Могу ли я перестроить код, чтобы не требовалось два владельца? → Передавайте по ссылке или используйте области видимости
- Это
Arc::clone()? → Это нормально, это O(1) - Я перемещаю данные в поток или замыкание? → Клонирование необходимо
- Я клонирую в горячем цикле? → Профилируйте и рассмотрите заимствование или
Cow<T>
Cow<'a, T>: Clone-on-Write — заимствуй, когда можешь, клонируй, когда должен
Cow (Clone on Write, «клонирование при записи») — это перечисление, которое хранит либо заимствованную ссылку, либо
владеющее значение. Это аналог в Rust идеи «избегай выделения памяти, когда возможно, но выделяй, если нужно изменить».
В C++ прямого аналога нет — ближайший вариант — функция, которая иногда возвращает const std::string&, а иногда std::string.
Зачем нужен Cow
#![allow(unused)]
fn main() {
// Без Cow — приходится выбирать: всегда заимствовать ИЛИ всегда клонировать
fn normalize(s: &str) -> String { // Всегда выделяет память!
if s.contains(' ') {
s.replace(' ', "_") // Новая String (выделение необходимо)
} else {
s.to_string() // Ненужное выделение памяти!
}
}
// С Cow — заимствуем, если строка не изменилась, выделяем только при изменении
use std::borrow::Cow;
fn normalize(s: &str) -> Cow<'_, str> {
if s.contains(' ') {
Cow::Owned(s.replace(' ', "_")) // Выделяет память (нужно изменить)
} else {
Cow::Borrowed(s) // Ноль выделений (проход насквозь)
}
}
}
Как работает Cow
use std::borrow::Cow;
// Cow<'a, str> — по сути:
// enum Cow<'a, str> {
// Borrowed(&'a str), // Ссылка без накладных расходов
// Owned(String), // Владеющее значение в куче
// }
fn greet(name: &str) -> Cow<'_, str> {
if name.is_empty() {
Cow::Borrowed("stranger") // Статическая строка — без выделения
} else if name.starts_with(' ') {
Cow::Owned(name.trim().to_string()) // Изменено — нужно выделение
} else {
Cow::Borrowed(name) // Проход насквозь — без выделения
}
}
fn main() {
let g1 = greet("Alice"); // Cow::Borrowed("Alice")
let g2 = greet(""); // Cow::Borrowed("stranger")
let g3 = greet(" Bob "); // Cow::Owned("Bob")
// Cow<str> реализует Deref<Target = str>, поэтому его можно использовать как &str:
println!("Hello, {g1}!"); // Работает — Cow автоматически разыменовывается в &str
println!("Hello, {g2}!");
println!("Hello, {g3}!");
}
Пример из практики: нормализация значений конфигурации
use std::borrow::Cow;
/// Нормализует имя SKU: убирает пробелы по краям, приводит к нижнему регистру.
/// Возвращает Cow::Borrowed, если строка уже нормализована (ноль выделений).
fn normalize_sku(sku: &str) -> Cow<'_, str> {
let trimmed = sku.trim();
if trimmed == sku && sku.chars().all(|c| c.is_lowercase() || !c.is_alphabetic()) {
Cow::Borrowed(sku) // Уже нормализована — без выделения
} else {
Cow::Owned(trimmed.to_lowercase()) // Нужно изменить — выделяем
}
}
fn main() {
let s1 = normalize_sku("server-x1"); // Borrowed — ноль выделений
let s2 = normalize_sku(" Server-X1 "); // Owned — приходится выделять
println!("{s1}, {s2}"); // "server-x1, server-x1"
}
Когда использовать Cow
| Ситуация | Использовать Cow? |
|---|---|
| Функция чаще всего возвращает вход без изменений | ✅ Да — избегаем ненужных клонирований |
| Разбор/нормализация строк (trim, lowercase, replace) | ✅ Да — часто вход уже корректен |
| Всегда что-то меняем — каждый путь выполнения выделяет память | ❌ Нет — просто возвращаем String |
| Простой проход насквозь (никогда не меняем) | ❌ Нет — просто возвращаем &str |
| Данные долгосрочно хранятся в структуре | ❌ Нет — используйте String (владеющую) |
Сравнение с C++:
Cow<str>похож на функцию, которая возвращаетstd::variant<std::string_view, std::string>, — только с автоматическим разыменованием и без шаблонного кода для доступа к значению.
Weak<T>: разрыв циклов ссылок — weak_ptr в Rust
Weak<T> — это аналог std::weak_ptr<T> из C++. Он хранит невладеющую ссылку на значение Rc<T> или Arc<T>. Значение может быть освобождено, пока существуют слабые ссылки Weak — вызов upgrade() вернёт None, если значения уже нет.
Зачем нужен Weak
Rc<T> и Arc<T> образуют циклы ссылок, если два значения указывают друг на друга — ни одно из них никогда не достигнет счётчика 0, поэтому ни одно не будет уничтожено (утечка памяти). Weak разрывает цикл:
use std::rc::{Rc, Weak};
use std::cell::RefCell;
#[derive(Debug)]
struct Node {
value: String,
parent: RefCell<Weak<Node>>, // Weak — не мешает родителю уничтожиться
children: RefCell<Vec<Rc<Node>>>, // Сильная — родитель владеет потомками
}
impl Node {
fn new(value: &str) -> Rc<Node> {
Rc::new(Node {
value: value.to_string(),
parent: RefCell::new(Weak::new()),
children: RefCell::new(Vec::new()),
})
}
fn add_child(parent: &Rc<Node>, child: &Rc<Node>) {
// У потомка слабая ссылка на родителя (цикла нет)
*child.parent.borrow_mut() = Rc::downgrade(parent);
// У родителя сильная ссылка на потомка
parent.children.borrow_mut().push(Rc::clone(child));
}
}
fn main() {
let root = Node::new("root");
let child = Node::new("child");
Node::add_child(&root, &child);
// Доступ к родителю из потомка через upgrade()
if let Some(parent) = child.parent.borrow().upgrade() {
println!("Child's parent: {}", parent.value); // "root"
}
println!("Root strong count: {}", Rc::strong_count(&root)); // 1
println!("Root weak count: {}", Rc::weak_count(&root)); // 1
}
Сравнение с C++
// C++ — weak_ptr для разрыва цикла shared_ptr
struct Node {
std::string value;
std::weak_ptr<Node> parent; // Слабая — без владения
std::vector<std::shared_ptr<Node>> children; // Сильные — владеет потомками
static auto create(const std::string& v) {
return std::make_shared<Node>(Node{v, {}, {}});
}
};
auto root = Node::create("root");
auto child = Node::create("child");
child->parent = root; // Присваивание weak_ptr
root->children.push_back(child);
if (auto p = child->parent.lock()) { // lock() → shared_ptr или null
std::cout << "Parent: " << p->value << std::endl;
}
| C++ | Rust | Примечания |
|---|---|---|
shared_ptr<T> | Rc<T> (один поток) / Arc<T> (несколько потоков) | Та же семантика |
weak_ptr<T> | Weak<T> из Rc::downgrade() / Arc::downgrade() | Та же семантика |
weak_ptr::lock() → shared_ptr или null | Weak::upgrade() → Option<Rc<T>> | None, если значение уничтожено |
shared_ptr::use_count() | Rc::strong_count() | Тот же смысл |
Когда использовать Weak
| Ситуация | Паттерн |
|---|---|
| Связи «родитель ↔ потомок» в дереве | Родитель хранит Rc<Child>, потомок хранит Weak<Parent> |
| Паттерн «наблюдатель» / слушатели событий | Источник событий хранит Weak<Observer>, наблюдатель хранит Rc<Source> |
| Кэш, который не мешает освобождению | HashMap<Key, Weak<Value>> — записи естественным образом устаревают |
| Разрыв циклов в графовых структурах | Перекрёстные связи используют Weak, рёбра дерева — Rc/Arc |
В новом коде предпочитайте паттерн арены (кейс 2) вместо
Rc/Weakдля древовидных структур.Vec<T>с индексами проще, быстрее и не имеет накладных расходов на подсчёт ссылок. ИспользуйтеRc/Weak, когда нужно совместное владение с динамическими временами жизни.
Copy и Clone, PartialEq и Eq — что и когда выводить через derive
- Copy ≈ тривиально копируемые типы C++ (без пользовательского конструктора копирования и деструктора). Такие типы, как
int,enumи простые POD-структуры, — компилятор автоматически генерирует побитовыйmemcpy. В RustCopy— та же идея: присваиваниеlet b = a;выполняет неявное побитовое копирование, и обе переменные остаются валидными. - Clone ≈ глубокое копирование в конструкторе копирования /
operator=C++. Когда у класса C++ есть пользовательский конструктор копирования (например, для глубокого копирования членаstd::vector), в Rust эквивалент — реализацияClone. Вызывать.clone()нужно явно — Rust никогда не прячет дорогое копирование за=. - Ключевое различие: в C++ и тривиальные, и глубокие копии происходят неявно через один и тот же синтаксис
=. Rust заставляет выбирать: типыCopyкопируются молча (дёшево), нециклопические типы по умолчанию перемещаются, а дорогое дублирование нужно включить явно через.clone(). - Аналогично, C++
operator==не различает типы, гдеa == aвсегда истинно (как целые), и типы, где это не так (какfloatс NaN). Rust кодирует это вPartialEqиEq.
Copy и Clone
| Copy | Clone | |
|---|---|---|
| Как работает | Побитовый memcpy (неявно) | Пользовательская логика (явно через .clone()) |
| Когда происходит | При присваивании: let b = a; | Только при вызове .clone() |
| После копирования/клонирования | И a, и b валидны | И a, и b валидны |
| Без них | let b = a; перемещает a (a больше нет) | let b = a; перемещает a (a больше нет) |
| Допустимо для | Типов без данных в куче | Любых типов |
| Аналогия в C++ | Тривиально копируемые / POD-типы (без пользовательского конструктора копирования) | Пользовательский конструктор копирования (глубокая копия) |
Реальный пример: Copy — простые перечисления
#![allow(unused)]
fn main() {
// Из fan_diag/src/sensor.rs — все варианты без данных, помещаются в 1 байт
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum FanStatus {
#[default]
Normal,
Low,
High,
Missing,
Failed,
Unknown,
}
let status = FanStatus::Normal;
let copy = status; // Неявное копирование — status по-прежнему валиден
println!("{:?} {:?}", status, copy); // Работает оба
}
Реальный пример: Copy — перечисление с целочисленными данными
#![allow(unused)]
fn main() {
// Пример: healthcheck.rs — полезная нагрузка u32 является Copy, значит, и всё перечисление тоже
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum HealthcheckStatus {
Pass,
ProgramError(u32),
DmesgError(u32),
RasError(u32),
OtherError(u32),
Unknown,
}
}
Реальный пример: только Clone — структура с данными в куче
#![allow(unused)]
fn main() {
// Пример: components.rs — String мешает Copy
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FruData {
pub technology: DeviceTechnology,
pub physical_location: String, // ← String: в куче, не может быть Copy
pub expected: bool,
pub removable: bool,
}
// let a = fru_data; → ПЕРЕМЕЩАЕТ (a — это единственный владелец, fru_data больше недоступна)
// let a = fru_data.clone(); → КЛОНИРУЕТ (fru_data по-прежнему валидна, новое выделение в куче)
}
Правило: может ли тип быть Copy?
Содержит ли тип String, Vec, Box, HashMap,
Rc, Arc или любой другой тип, владеющий кучей?
ДА → только Clone (не может быть Copy)
НЕТ → МОЖНО выводить Copy (и стоит, если тип небольшой)
PartialEq и Eq
| PartialEq | Eq | |
|---|---|---|
| Что даёт | Операторы == и != | Маркер: «равенство рефлексивно» |
| Рефлексивно? (a == a) | Не гарантируется | Гарантируется |
| Почему важно | f32::NAN != f32::NAN | Ключи HashMap требуют Eq |
| Когда выводить | Почти всегда | Когда в типе нет полей f32/f64 |
| Аналогия в C++ | operator== | Прямого аналога нет (C++ не проверяет) |
Реальный пример: Eq — используется как ключ HashMap
#![allow(unused)]
fn main() {
// Из hms_trap/src/cpu_handler.rs — Hash требует Eq
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CpuFaultType {
InvalidFaultType,
CpuCperFatalErr,
CpuLpddr5UceErr,
CpuC2CUceFatalErr,
// ...
}
// Используется как: HashMap<CpuFaultType, FaultHandler>
// Ключи HashMap должны быть Eq + Hash — одного PartialEq недостаточно для компиляции
}
Реальный пример: Eq невозможен — тип содержит f32
#![allow(unused)]
fn main() {
// Пример: types.rs — f32 не позволяет Eq
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TemperatureSensors {
pub warning_threshold: Option<f32>, // ← f32: NaN ≠ NaN
pub critical_threshold: Option<f32>, // ← нельзя вывести Eq
pub sensor_names: Vec<String>,
}
// Нельзя использовать как ключ HashMap. Нельзя вывести Eq.
// Потому что: f32::NAN == f32::NAN ложно, что нарушает рефлексивность.
}
PartialOrd и Ord
| PartialOrd | Ord | |
|---|---|---|
| Что даёт | <, >, <=, >= | .sort(), ключи BTreeMap |
| Полный порядок? | Нет (некоторые пары могут быть несравнимы) | Да (любые два значения сравнимы) |
| f32/f64? | Только PartialOrd (NaN ломает порядок) | Нельзя вывести Ord |
Реальный пример: Ord — ранжирование серьёзности
#![allow(unused)]
fn main() {
// Из hms_trap/src/fault.rs — порядок вариантов задаёт серьёзность
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum FaultSeverity {
Info, // самый низкий (дискриминант 0)
Warning, // (дискриминант 1)
Error, // (дискриминант 2)
Critical, // самый высокий (дискриминант 3)
}
// FaultSeverity::Info < FaultSeverity::Critical → true
// Позволяет: if severity >= FaultSeverity::Error { escalate(); }
}
Реальный пример: Ord — уровни диагностики для сравнения
#![allow(unused)]
fn main() {
// Пример: orchestration.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum GpuDiagLevel {
#[default]
Quick, // самый низкий
Standard,
Extended,
Full, // самый высокий
}
// Позволяет: if requested_level >= GpuDiagLevel::Extended { run_extended_tests(); }
}
Дерево решений по derive
Ваш новый тип
│
Содержит String/Vec/Box?
/ \
ДА НЕТ
│ │
Только Clone Clone + Copy
│ │
Содержит f32/f64? Содержит f32/f64?
/ \ / \
ДА НЕТ ДА НЕТ
│ │ │ │
Только PartialEq Только PartialEq
PartialEq + Eq PartialEq + Eq
│ │
Нужна сортировка? Нужна сортировка?
/ \ / \
ДА НЕТ ДА НЕТ
│ │ │ │
PartialOrd Готово PartialOrd Готово
+ Ord + Ord
│ │
Нужен как Нужен как
ключ словаря? ключ словаря?
│ │
+ Hash + Hash
Краткий справочник: типичные комбинации derive из продакшн-кода на Rust
| Категория типа | Типичный derive | Пример |
|---|---|---|
| Простое перечисление статусов | Copy, Clone, PartialEq, Eq, Default | FanStatus |
| Перечисление как ключ HashMap | Copy, Clone, PartialEq, Eq, Hash | CpuFaultType, SelComponent |
| Сортируемое перечисление серьёзности | Copy, Clone, PartialEq, Eq, PartialOrd, Ord | FaultSeverity, GpuDiagLevel |
| Структура данных со строками | Clone, Debug, Serialize, Deserialize | FruData, OverallSummary |
| Сериализуемая конфигурация | Clone, Debug, Default, Serialize, Deserialize | DiagConfig |
Избегание неконтролируемой индексации
Избегание неконтролируемой индексации
Что вы узнаете: почему
vec[i]опасен в Rust (вызывает panic при выходе за границы), и безопасные альтернативы —.get(), итераторы и APIentry()дляHashMap. Они заменяют неопределённое поведение C++ явной обработкой.
- В C++
vec[i]иmap[key]ведут к неопределённому поведению или вставляют новый ключ, если его не было.[]в Rust вызывает panic при выходе за границы. - Правило: используйте
.get()вместо[], если не можете доказать, что индекс корректен.
Сравнение C++ → Rust
// C++ — тихое неопределённое поведение или вставка
std::vector<int> v = {1, 2, 3};
int x = v[10]; // Неопределённое поведение! operator[] не проверяет границы
std::map<std::string, int> m;
int y = m["missing"]; // Тихо вставляет ключ со значением 0!
#![allow(unused)]
fn main() {
// Rust — безопасные альтернативы
let v = vec![1, 2, 3];
// Плохо: вызывает panic, если индекс вне границ
// let x = v[10];
// Хорошо: возвращает Option<&i32>
let x = v.get(10); // None — без panic
let x = v.get(1).copied().unwrap_or(0); // 2, или 0, если элемента нет
}
Реальный пример: безопасный разбор байтов из продакшн-кода на Rust
#![allow(unused)]
fn main() {
// Пример: diagnostics.rs
// Разбор бинарной записи SEL — буфер может оказаться короче, чем ожидалось
let sensor_num = bytes.get(7).copied().unwrap_or(0);
let ppin = cpu_ppin.get(i).map(|s| s.as_str()).unwrap_or("");
}
Реальный пример: цепочка безопасных поисков через .and_then()
#![allow(unused)]
fn main() {
// Пример: profile.rs — двойной поиск: HashMap → Vec
pub fn get_processor(&self, location: &str) -> Option<&Processor> {
self.processor_by_location
.get(location) // HashMap → Option<&usize>
.and_then(|&idx| self.processors.get(idx)) // Vec → Option<&Processor>
}
// Оба поиска возвращают Option — ни panic, ни неопределённого поведения
}
Реальный пример: безопасная навигация по JSON
#![allow(unused)]
fn main() {
// Пример: framework.rs — каждый ключ JSON возвращает Option
let manufacturer = product_fru
.get("Manufacturer") // Option<&Value>
.and_then(|v| v.as_str()) // Option<&str>
.unwrap_or(UNKNOWN_VALUE) // &str (безопасное значение по умолчанию)
.to_string();
}
Сравните с паттерном C++: json["SystemInfo"]["ProductFru"]["Manufacturer"] — любой отсутствующий ключ выбрасывает nlohmann::json::out_of_range.
Когда [] допустим
- После проверки границ:
if i < v.len() { v[i] } - В тестах: где panic — желаемое поведение
- С константами:
let first = v[0];сразу послеassert!(!v.is_empty());
Безопасное извлечение значения с unwrap_or
unwrap()вызывает panic приNone/Err. В продакшн-коде предпочитайте безопасные альтернативы.
Семейство unwrap
| Метод | Поведение при None/Err | Когда использовать |
|---|---|---|
.unwrap() | Panic | Только в тестах или когда ошибка исключена |
.expect("msg") | Panic с сообщением | Когда panic оправдан — объясните почему |
.unwrap_or(default) | Возвращает default | Есть дешёвое константное значение по умолчанию |
.unwrap_or_else(|| expr) | Вызывает замыкание | Значение по умолчанию дорого вычислять |
.unwrap_or_default() | Возвращает Default::default() | Тип реализует Default |
Реальный пример: разбор с безопасными значениями по умолчанию
#![allow(unused)]
fn main() {
// Пример: peripherals.rs
// Группы захвата регулярного выражения могут не совпасть — предусматриваем безопасные значения
let bus_hex = caps.get(1).map(|m| m.as_str()).unwrap_or("00");
let fw_status = caps.get(5).map(|m| m.as_str()).unwrap_or("0x0");
let bus = u8::from_str_radix(bus_hex, 16).unwrap_or(0);
}
Реальный пример: unwrap_or_else с запасной структурой
#![allow(unused)]
fn main() {
// Пример: framework.rs
// Вся функция оборачивает логику в замыкание, возвращающее Option;
// если что-то не получилось, возвращаем структуру по умолчанию:
(|| -> Option<BaseboardFru> {
let content = std::fs::read_to_string(path).ok()?;
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
// ... извлекаем поля цепочками .get()?
Some(baseboard_fru)
})()
.unwrap_or_else(|| BaseboardFru {
manufacturer: String::new(),
model: String::new(),
product_part_number: String::new(),
serial_number: String::new(),
asset_tag: String::new(),
})
}
Реальный пример: unwrap_or_default при десериализации конфигурации
#![allow(unused)]
fn main() {
// Пример: framework.rs
// Если разбор JSON-конфигурации не удался, используем Default — без падения
Ok(json) => serde_json::from_str(&json).unwrap_or_default(),
}
Аналог в C++ — try/catch вокруг nlohmann::json::parse() с ручным созданием значения по умолчанию в блоке catch.
Функциональные преобразования: map, map_err, find_map
- Эти методы у
OptionиResultпозволяют преобразовывать содержащееся значение без распаковки, заменяя вложенныеif/elseлинейными цепочками.
Краткий справочник
| Метод | Применяется к | Что делает | Аналог в C++ |
|---|---|---|---|
.map(|v| ...) | Option / Result | Преобразует значение Some/Ok | if (opt) { *opt = transform(*opt); } |
.map_err(|e| ...) | Result | Преобразует значение Err | Добавление контекста в блок catch |
.and_then(|v| ...) | Option / Result | Объединяет в цепочку операции, возвращающие Option/Result | Вложенные проверки if |
.find_map(|v| ...) | Итератор | find + map за один проход | Цикл с if + break |
.filter(|v| ...) | Option / Итератор | Оставляет только значения, подходящие под предикат | if (!predicate) return nullopt; |
.ok()? | Result | Преобразует Result → Option и передаёт None дальше | if (result.has_error()) return nullopt; |
Реальный пример: цепочка .and_then() для извлечения полей JSON
#![allow(unused)]
fn main() {
// Пример: framework.rs — поиск серийного номера с запасными вариантами
let sys_info = json.get("SystemInfo")?;
// Сначала пробуем BaseboardFru.BoardSerialNumber
if let Some(serial) = sys_info
.get("BaseboardFru")
.and_then(|b| b.get("BoardSerialNumber"))
.and_then(|v| v.as_str())
.filter(valid_serial) // Принимаем только непустые корректные серийные номера
{
return Some(serial.to_string());
}
// Запасной вариант: BoardFru.SerialNumber
sys_info
.get("BoardFru")
.and_then(|b| b.get("SerialNumber"))
.and_then(|v| v.as_str())
.filter(valid_serial)
.map(|s| s.to_string()) // Преобразуем &str → String, только если Some
}
В C++ это была бы пирамида if (json.contains("BaseboardFru")) { if (json["BaseboardFru"].contains("BoardSerialNumber")) { ... } }.
Реальный пример: find_map — поиск и преобразование за один проход
#![allow(unused)]
fn main() {
// Пример: context.rs — находим запись SDR, соответствующую датчику и владельцу
pub fn find_for_event(&self, sensor_number: u8, owner_id: u8) -> Option<&SdrRecord> {
self.by_sensor.get(&sensor_number).and_then(|indices| {
indices.iter().find_map(|&i| {
let record = &self.records[i];
if record.sensor_owner_id() == Some(owner_id) {
Some(record)
} else {
None
}
})
})
}
}
find_map — это find и map в одном: останавливается на первом совпадении и сразу преобразует его. Аналог в C++ — цикл for с if и break.
Реальный пример: map_err для контекста ошибки
#![allow(unused)]
fn main() {
// Пример: main.rs — добавляем контекст к ошибкам перед передачей наверх
let json_str = serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize config: {}", e))?;
}
Преобразует serde_json::Error в понятную ошибку String, которая содержит контекст о том, что именно не удалось.
Обработка JSON: nlohmann::json → serde
- Команды C++ обычно используют
nlohmann::jsonдля разбора JSON. В Rust используются serde и serde_json — это мощнее, потому что схема JSON кодируется в системе типов.
Сравнение C++ (nlohmann) и Rust (serde)
// C++ с nlohmann::json — доступ к полям во время выполнения
#include <nlohmann/json.hpp>
using json = nlohmann::json;
struct Fan {
std::string logical_id;
std::vector<std::string> sensor_ids;
};
Fan parse_fan(const json& j) {
Fan f;
f.logical_id = j.at("LogicalID").get<std::string>(); // бросает исключение, если нет
if (j.contains("SDRSensorIdHexes")) { // ручная обработка значения по умолчанию
f.sensor_ids = j["SDRSensorIdHexes"].get<std::vector<std::string>>();
}
return f;
}
#![allow(unused)]
fn main() {
// Rust с serde — схема на этапе компиляции, автоматическое сопоставление полей
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Fan {
pub logical_id: String,
#[serde(rename = "SDRSensorIdHexes", default)] // Ключ JSON → поле Rust
pub sensor_ids: Vec<String>, // Нет в JSON → пустой Vec
#[serde(default)]
pub sensor_names: Vec<String>, // Нет в JSON → пустой Vec
}
// Одна строка заменяет всю функцию разбора:
let fan: Fan = serde_json::from_str(json_str)?;
}
Ключевые атрибуты serde (реальные примеры из продакшн-кода на Rust)
| Атрибут | Назначение | Аналог в C++ |
|---|---|---|
#[serde(default)] | Использовать Default::default() для отсутствующих полей | if (j.contains(key)) { ... } else { default; } |
#[serde(rename = "Key")] | Сопоставить имя ключа JSON с именем поля Rust | Ручной доступ j.at("Key") |
#[serde(flatten)] | Поглощать неизвестные ключи в HashMap | for (auto& [k,v] : j.items()) { ... } |
#[serde(skip)] | Не сериализовать/десериализовать это поле | Не сохранять в JSON |
#[serde(tag = "type")] | Перечисление с внутренним тегом (поле-дискриминатор) | if (j["type"] == "gpu") { ... } |
Реальный пример: полная структура конфигурации
#![allow(unused)]
fn main() {
// Пример: diag.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiagConfig {
pub sku: SkuConfig,
#[serde(default)]
pub level: DiagLevel, // Нет в JSON → DiagLevel::default()
#[serde(default)]
pub modules: ModuleConfig, // Нет в JSON → ModuleConfig::default()
#[serde(default)]
pub output_dir: String, // Нет в JSON → ""
#[serde(default, flatten)]
pub options: HashMap<String, serde_json::Value>, // Поглощает неизвестные ключи
}
// Загрузка — 3 строки (против ~20+ в C++ с nlohmann):
let content = std::fs::read_to_string(path)?;
let config: DiagConfig = serde_json::from_str(&content)?;
Ok(config)
}
Десериализация перечислений с #[serde(tag = "type")]
#![allow(unused)]
fn main() {
// Пример: components.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")] // JSON: {"type": "Gpu", "product": ...}
pub enum PcieDeviceKind {
Gpu { product: GpuProduct, manufacturer: GpuManufacturer },
Nic { product: NicProduct, manufacturer: NicManufacturer },
NvmeDrive { drive_type: StorageDriveType, capacity_gb: u32 },
// ... ещё 9 вариантов
}
// serde автоматически выбирает вариант по полю "type" — без цепочки if/else вручную
}
Аналог в C++: if (j["type"] == "Gpu") { parse_gpu(j); } else if (j["type"] == "Nic") { parse_nic(j); } ...
Упражнение: десериализация JSON с serde
- Определите структуру
ServerConfig, которую можно десериализовать из следующего JSON:
{
"hostname": "diag-node-01",
"port": 8080,
"debug": true,
"modules": ["accel_diag", "nic_diag", "cpu_diag"]
}
- Используйте
#[derive(Deserialize)]иserde_json::from_str()для разбора - Добавьте
#[serde(default)]к полюdebug, чтобы оно по умолчанию былоfalse, если отсутствует - Бонус: добавьте поле
enum DiagLevel { Quick, Full, Extended }с#[serde(default)], которое по умолчанию равноQuick
Стартовый код (нужны cargo add serde --features derive и cargo add serde_json):
use serde::Deserialize;
// TODO: Определите перечисление DiagLevel с реализацией Default
// TODO: Определите структуру ServerConfig с атрибутами serde
fn main() {
let json_input = r#"{
"hostname": "diag-node-01",
"port": 8080,
"debug": true,
"modules": ["accel_diag", "nic_diag", "cpu_diag"]
}"#;
// TODO: Десериализуйте и выведите конфигурацию
// TODO: Попробуйте разобрать JSON без поля "debug" — убедитесь, что оно по умолчанию false
}
Решение (нажмите, чтобы развернуть)
use serde::Deserialize;
#[derive(Debug, Deserialize, Default)]
enum DiagLevel {
#[default]
Quick,
Full,
Extended,
}
#[derive(Debug, Deserialize)]
struct ServerConfig {
hostname: String,
port: u16,
#[serde(default)] // по умолчанию false, если отсутствует
debug: bool,
modules: Vec<String>,
#[serde(default)] // по умолчанию DiagLevel::Quick, если отсутствует
level: DiagLevel,
}
fn main() {
let json_input = r#"{
"hostname": "diag-node-01",
"port": 8080,
"debug": true,
"modules": ["accel_diag", "nic_diag", "cpu_diag"]
}"#;
let config: ServerConfig = serde_json::from_str(json_input)
.expect("Failed to parse JSON");
println!("{config:#?}");
// Проверка с отсутствующими необязательными полями
let minimal = r#"{
"hostname": "node-02",
"port": 9090,
"modules": []
}"#;
let config2: ServerConfig = serde_json::from_str(minimal)
.expect("Failed to parse minimal JSON");
println!("debug (default): {}", config2.debug); // false
println!("level (default): {:?}", config2.level); // Quick
}
// Вывод:
// ServerConfig {
// hostname: "diag-node-01",
// port: 8080,
// debug: true,
// modules: ["accel_diag", "nic_diag", "cpu_diag"],
// level: Quick,
// }
// debug (default): false
// level (default): Quick
Схлопывание пирамид присваиваний
Схлопывание пирамид присваиваний с помощью замыканий
Что вы узнаете: как выразительный синтаксис Rust на основе выражений и замыкания превращают глубоко вложенные цепочки проверок
if/elseиз C++ в чистый линейный код.
- В C++ для присваивания переменных часто нужны многоблочные цепочки
if/else, особенно когда есть проверки или логика запасных вариантов. Синтаксис Rust на основе выражений и замыкания сворачивают их в плоский линейный код.
Паттерн 1: присваивание кортежа через выражение if
// C++ — три переменные задаются в многоблочной цепочке if/else
uint32_t fault_code;
const char* der_marker;
const char* action;
if (is_c44ad) {
fault_code = 32709; der_marker = "CSI_WARN"; action = "No action";
} else if (error.is_hardware_error()) {
fault_code = 67956; der_marker = "CSI_ERR"; action = "Replace GPU";
} else {
fault_code = 32709; der_marker = "CSI_WARN"; action = "No action";
}
#![allow(unused)]
fn main() {
// Эквивалент на Rust:accel_fieldiag.rs
// Одно выражение присваивает все три сразу:
let (fault_code, der_marker, recommended_action) = if is_c44ad {
(32709u32, "CSI_WARN", "No action")
} else if error.is_hardware_error() {
(67956u32, "CSI_ERR", "Replace GPU")
} else {
(32709u32, "CSI_WARN", "No action")
};
}
Паттерн 2: IIFE (немедленно вызываемое функциональное выражение) для цепочек с возможными ошибками
// C++ — «пирамида гибели» для навигации по JSON
std::string get_part_number(const nlohmann::json& root) {
if (root.contains("SystemInfo")) {
auto& sys = root["SystemInfo"];
if (sys.contains("BaseboardFru")) {
auto& bb = sys["BaseboardFru"];
if (bb.contains("ProductPartNumber")) {
return bb["ProductPartNumber"].get<std::string>();
}
}
}
return "UNKNOWN";
}
#![allow(unused)]
fn main() {
// Эквивалент на Rust:framework.rs
// Замыкание + оператор ? превращают пирамиду в линейный код:
let part_number = (|| -> Option<String> {
let path = self.args.sysinfo.as_ref()?;
let content = std::fs::read_to_string(path).ok()?;
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
let ppn = json
.get("SystemInfo")?
.get("BaseboardFru")?
.get("ProductPartNumber")?
.as_str()?;
Some(ppn.to_string())
})()
.unwrap_or_else(|| "UNKNOWN".to_string());
}
Замыкание создаёт область Option<String>, в которой ? досрочно прерывает выполнение на любом шаге. .unwrap_or_else() задаёт запасной вариант один раз, в конце.
Паттерн 3: цепочка итераторов вместо ручного цикла + push_back
// C++ — ручной цикл с промежуточными переменными
std::vector<std::tuple<std::vector<std::string>, std::string, std::string>> gpu_info;
for (const auto& [key, info] : gpu_pcie_map) {
std::vector<std::string> bdfs;
// ... разбираем bdf_path в bdfs
std::string serial = info.serial_number.value_or("UNKNOWN");
std::string model = info.model_number.value_or(model_name);
gpu_info.push_back({bdfs, serial, model});
}
#![allow(unused)]
fn main() {
// Эквивалент на Rust:peripherals.rs
// Одна цепочка: values() → map → collect
let gpu_info: Vec<(Vec<String>, String, String, String)> = self
.gpu_pcie_map
.values()
.map(|info| {
let bdfs: Vec<String> = info.bdf_path
.split(')')
.filter(|s| !s.is_empty())
.map(|s| s.trim_start_matches('(').to_string())
.collect();
let serial = info.serial_number.clone()
.unwrap_or_else(|| "UNKNOWN".to_string());
let model = info.model_number.clone()
.unwrap_or_else(|| model_name.to_string());
let gpu_bdf = format!("{}:{}:{}.{}",
info.bdf.segment, info.bdf.bus, info.bdf.device, info.bdf.function);
(bdfs, serial, model, gpu_bdf)
})
.collect();
}
Паттерн 4: .filter().collect() вместо цикла + if (condition) continue
// C++
std::vector<TestResult*> failures;
for (auto& t : test_results) {
if (!t.is_pass()) {
failures.push_back(&t);
}
}
#![allow(unused)]
fn main() {
// Rust — из accel_diag/src/healthcheck.rs
pub fn failed_tests(&self) -> Vec<&TestResult> {
self.test_results.iter().filter(|t| !t.is_pass()).collect()
}
}
Итоги: когда применять каждый паттерн
| Паттерн C++ | Замена в Rust | Ключевое преимущество |
|---|---|---|
Присваивание переменных в многоблочном if | let (a, b) = if ... { } else { }; | Все переменные связываются атомарно |
Вложенная пирамида if (contains) | IIFE-замыкание с оператором ? | Линейно, плоско, ранний выход |
Цикл for + push_back | .iter().map(||).collect() | Не нужен промежуточный изменяемый Vec |
for + if (cond) continue | .iter().filter(||).collect() | Декларативное намерение |
for + if + break (поиск первого) | .iter().find_map(||) | Поиск и преобразование за один проход |
Итоговое упражнение: конвейер диагностических событий
🔴 Сложный уровень — комплексное упражнение, объединяющее перечисления, трейты, итераторы, обработку ошибок и обобщения
Это комплексное упражнение объединяет перечисления, трейты, итераторы, обработку ошибок и обобщения. Вы создадите упрощённый конвейер обработки диагностических событий, похожий на паттерны, используемые в продакшн-коде на Rust.
Требования:
- Определите
enum Severity { Info, Warning, Critical }с реализациейDisplay, а такжеstruct DiagEvent, содержащуюsource: String,severity: Severity,message: Stringиfault_code: u32 - Определите
trait EventFilterс методомfn should_include(&self, event: &DiagEvent) -> bool - Реализуйте два фильтра:
SeverityFilter(только события с серьёзностью >= заданной) иSourceFilter(только события от определённого источника) - Напишите функцию
fn process_events(events: &[DiagEvent], filters: &[&dyn EventFilter]) -> Vec<String>, которая возвращает отформатированные строки отчёта для событий, прошедших все фильтры - Напишите
fn parse_event(line: &str) -> Result<DiagEvent, String>, которая разбирает строки вида"source:severity:fault_code:message"(возвращаетErrдля некорректного ввода)
Стартовый код:
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum Severity {
Info,
Warning,
Critical,
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
todo!()
}
}
#[derive(Debug, Clone)]
struct DiagEvent {
source: String,
severity: Severity,
message: String,
fault_code: u32,
}
trait EventFilter {
fn should_include(&self, event: &DiagEvent) -> bool;
}
struct SeverityFilter {
min_severity: Severity,
}
// TODO: impl EventFilter for SeverityFilter
struct SourceFilter {
source: String,
}
// TODO: impl EventFilter for SourceFilter
fn process_events(events: &[DiagEvent], filters: &[&dyn EventFilter]) -> Vec<String> {
// TODO: Отфильтровать события, прошедшие ВСЕ фильтры, и отформатировать как
// "[SEVERITY] source (FC:fault_code): message"
todo!()
}
fn parse_event(line: &str) -> Result<DiagEvent, String> {
// Разобрать "source:severity:fault_code:message"
// Вернуть Err для некорректного ввода
todo!()
}
fn main() {
let raw_lines = vec![
"accel_diag:Critical:67956:ECC uncorrectable error detected",
"nic_diag:Warning:32709:Link speed degraded",
"accel_diag:Info:10001:Self-test passed",
"cpu_diag:Critical:55012:Thermal throttling active",
"accel_diag:Warning:32710:PCIe link width reduced",
];
// Разобрать все строки, собрать успешные и вывести ошибки разбора
let events: Vec<DiagEvent> = raw_lines.iter()
.filter_map(|line| match parse_event(line) {
Ok(e) => Some(e),
Err(e) => { eprintln!("Parse error: {e}"); None }
})
.collect();
// Применяем фильтры: только Critical и Warning от accel_diag
let sev_filter = SeverityFilter { min_severity: Severity::Warning };
let src_filter = SourceFilter { source: "accel_diag".to_string() };
let filters: Vec<&dyn EventFilter> = vec![&sev_filter, &src_filter];
let report = process_events(&events, &filters);
for line in &report {
println!("{line}");
}
println!("--- {} event(s) matched ---", report.len());
}
Решение (нажмите, чтобы развернуть)
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum Severity {
Info,
Warning,
Critical,
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Severity::Info => write!(f, "INFO"),
Severity::Warning => write!(f, "WARNING"),
Severity::Critical => write!(f, "CRITICAL"),
}
}
}
impl Severity {
fn from_str(s: &str) -> Result<Self, String> {
match s {
"Info" => Ok(Severity::Info),
"Warning" => Ok(Severity::Warning),
"Critical" => Ok(Severity::Critical),
other => Err(format!("Unknown severity: {other}")),
}
}
}
#[derive(Debug, Clone)]
struct DiagEvent {
source: String,
severity: Severity,
message: String,
fault_code: u32,
}
trait EventFilter {
fn should_include(&self, event: &DiagEvent) -> bool;
}
struct SeverityFilter {
min_severity: Severity,
}
impl EventFilter for SeverityFilter {
fn should_include(&self, event: &DiagEvent) -> bool {
event.severity >= self.min_severity
}
}
struct SourceFilter {
source: String,
}
impl EventFilter for SourceFilter {
fn should_include(&self, event: &DiagEvent) -> bool {
event.source == self.source
}
}
fn process_events(events: &[DiagEvent], filters: &[&dyn EventFilter]) -> Vec<String> {
events.iter()
.filter(|e| filters.iter().all(|f| f.should_include(e)))
.map(|e| format!("[{}] {} (FC:{}): {}", e.severity, e.source, e.fault_code, e.message))
.collect()
}
fn parse_event(line: &str) -> Result<DiagEvent, String> {
let parts: Vec<&str> = line.splitn(4, ':').collect();
if parts.len() != 4 {
return Err(format!("Expected 4 colon-separated fields, got {}", parts.len()));
}
let fault_code = parts[2].parse::<u32>()
.map_err(|e| format!("Invalid fault code '{}': {e}", parts[2]))?;
Ok(DiagEvent {
source: parts[0].to_string(),
severity: Severity::from_str(parts[1])?,
fault_code,
message: parts[3].to_string(),
})
}
fn main() {
let raw_lines = vec![
"accel_diag:Critical:67956:ECC uncorrectable error detected",
"nic_diag:Warning:32709:Link speed degraded",
"accel_diag:Info:10001:Self-test passed",
"cpu_diag:Critical:55012:Thermal throttling active",
"accel_diag:Warning:32710:PCIe link width reduced",
];
let events: Vec<DiagEvent> = raw_lines.iter()
.filter_map(|line| match parse_event(line) {
Ok(e) => Some(e),
Err(e) => { eprintln!("Parse error: {e}"); None }
})
.collect();
let sev_filter = SeverityFilter { min_severity: Severity::Warning };
let src_filter = SourceFilter { source: "accel_diag".to_string() };
let filters: Vec<&dyn EventFilter> = vec![&sev_filter, &src_filter];
let report = process_events(&events, &filters);
for line in &report {
println!("{line}");
}
println!("--- {} event(s) matched ---", report.len());
}
// Вывод:
// [CRITICAL] accel_diag (FC:67956): ECC uncorrectable error detected
// [WARNING] accel_diag (FC:32710): PCIe link width reduced
// --- 2 event(s) matched ---
Экосистема логирования и трассировки
Экосистема логирования и трассировки: syslog/printf → log + tracing
Что вы узнаете: двухуровневую архитектуру логирования в Rust (фасад + бэкенд), крейты
logиtracing, структурированное логирование со спанами и то, чем это заменяет отладку черезprintf/syslog.
Диагностический код на C++ обычно использует printf, syslog или собственные фреймворки логирования.
В Rust есть стандартизированная двухуровневая архитектура логирования: крейт-фасад (log или
tracing) и бэкенд (собственно реализация логгера).
Фасад log — универсальный API логирования в Rust
Крейт log предоставляет макросы, которые повторяют уровни серьёзности syslog. Библиотеки используют
макросы log, а бинарные крейты выбирают бэкенд:
// Cargo.toml
// [dependencies]
// log = "0.4"
// env_logger = "0.11" # Один из многих бэкендов
use log::{info, warn, error, debug, trace};
fn check_sensor(id: u32, temp: f64) {
trace!("Reading sensor {id}"); // Самая мелкая детализация
debug!("Sensor {id} raw value: {temp}"); // Подробности для разработки
if temp > 85.0 {
warn!("Sensor {id} high temperature: {temp}°C");
}
if temp > 95.0 {
error!("Sensor {id} CRITICAL: {temp}°C — initiating shutdown");
}
info!("Sensor {id} check complete"); // Штатная работа
}
fn main() {
// Инициализация бэкенда — обычно делается один раз в main()
env_logger::init(); // Управляется переменной окружения RUST_LOG
check_sensor(0, 72.5);
check_sensor(1, 91.0);
}
# Управление уровнем логирования через переменную окружения
RUST_LOG=debug cargo run # Показать debug и выше
RUST_LOG=warn cargo run # Показать только warn и error
RUST_LOG=my_crate=trace cargo run # Фильтрация по модулям
RUST_LOG=my_crate::gpu=debug,warn cargo run # Разные уровни для разных частей
Сравнение с C++
| C++ | Rust (log) | Примечания |
|---|---|---|
printf("DEBUG: %s\n", msg) | debug!("{msg}") | Формат проверяется на этапе компиляции |
syslog(LOG_ERR, "...") | error!("...") | Бэкенд решает, куда попадёт вывод |
#ifdef DEBUG вокруг вызовов логирования | trace! / debug! удаляются при max_level | Нулевая стоимость, когда отключено |
Собственный Logger::log(level, msg) | log::info!("...") — все крейты используют один API | Универсальный фасад, заменяемый бэкенд |
| Детализация логов для каждого файла | RUST_LOG=crate::module=level | Настраивается через окружение, без перекомпиляции |
Крейт tracing — структурированное логирование со спанами
tracing расширяет log структурированными полями и спанами (ограниченными по времени областями).
Это особенно полезно в диагностическом коде, где нужно отслеживать контекст:
// Cargo.toml
// [dependencies]
// tracing = "0.1"
// tracing-subscriber = { version = "0.3", features = ["env-filter"] }
use tracing::{info, warn, error, instrument, info_span};
#[instrument(skip(data), fields(gpu_id = gpu_id, data_len = data.len()))]
fn run_gpu_test(gpu_id: u32, data: &[u8]) -> Result<(), String> {
info!("Starting GPU test");
let span = info_span!("ecc_check", gpu_id);
let _guard = span.enter(); // Все логи внутри этой области включают gpu_id
if data.is_empty() {
error!(gpu_id, "No test data provided");
return Err("empty data".to_string());
}
// Структурированные поля — пригодны для машинного разбора, а не просто подстановка строк
info!(
gpu_id,
temp_celsius = 72.5,
ecc_errors = 0,
"ECC check passed"
);
Ok(())
}
fn main() {
// Инициализация подписчика tracing
tracing_subscriber::fmt()
.with_env_filter("debug") // Или используйте переменную окружения RUST_LOG
.with_target(true) // Показывать путь модуля
.with_thread_ids(true) // Показывать ID потоков
.init();
let _ = run_gpu_test(0, &[1, 2, 3]);
}
Вывод tracing-subscriber:
#![allow(unused)]
fn main() {
2026-02-15T10:30:00.123Z DEBUG ThreadId(01) run_gpu_test{gpu_id=0 data_len=3}: my_crate: Starting GPU test
2026-02-15T10:30:00.124Z INFO ThreadId(01) run_gpu_test{gpu_id=0 data_len=3}:ecc_check{gpu_id=0}: my_crate: ECC check passed gpu_id=0 temp_celsius=72.5 ecc_errors=0
}
#[instrument] — автоматическое создание спанов
Атрибут #[instrument] автоматически создаёт спан с именем функции
и её аргументами:
#![allow(unused)]
fn main() {
use tracing::instrument;
#[instrument]
fn parse_sel_record(record_id: u16, sensor_type: u8, data: &[u8]) -> Result<(), String> {
// Каждый лог внутри этой функции автоматически включает:
// record_id, sensor_type и data (если реализован Debug)
tracing::debug!("Parsing SEL record");
Ok(())
}
// skip: исключить из спана большие/чувствительные аргументы
// fields: добавить вычисляемые поля
#[instrument(skip(raw_buffer), fields(buf_len = raw_buffer.len()))]
fn decode_ipmi_response(raw_buffer: &[u8]) -> Result<Vec<u8>, String> {
tracing::trace!("Decoding {} bytes", raw_buffer.len());
Ok(raw_buffer.to_vec())
}
}
log или tracing — что выбрать
| Аспект | log | tracing |
|---|---|---|
| Сложность | Простой — 5 макросов | Богаче — спаны, поля, instrument |
| Структурированные данные | Только подстановка строк | Поля ключ-значение: info!(gpu_id = 0, "msg") |
| Время / спаны | Нет | Да — #[instrument], span.enter() |
| Поддержка async | Базовая | Полноценная — спаны передаются через .await |
| Совместимость | Универсальный фасад | Совместим с log (есть мост log) |
| Когда использовать | Простые приложения, библиотеки | Диагностические инструменты, async-код, наблюдаемость |
Рекомендация: используйте
tracingдля продакшн-проектов диагностического характера (диагностические инструменты со структурированным выводом). Используйтеlogдля простых библиотек, где нужно минимум зависимостей.tracingвключает слой совместимости, так что библиотеки, использующие макросыlog, продолжат работать с подписчикомtracing.
Варианты бэкендов
| Крейт бэкенда | Вывод | Сценарий использования |
|---|---|---|
env_logger | stderr, цветной | Разработка, простые CLI-инструменты |
tracing-subscriber | stderr, форматированный | Продакшн с tracing |
syslog | Системный syslog | Системные службы Linux |
tracing-journald | Журнал systemd | Службы под управлением systemd |
tracing-appender | Ротируемые файлы логов | Долгоживущие демоны |
tracing-opentelemetry | Коллектор OpenTelemetry | Распределённая трассировка |
18. Семантические различия C++ и Rust
Семантические различия C++ → Rust: углублённый разбор
Что вы узнаете: подробное соответствие концепций C++, у которых нет очевидного аналога в Rust, — четыре именованных приведения, SFINAE и ограничения трейтами, CRTP и ассоциированные типы и другие частые трудности при переводе.
Ниже описаны концепции C++, для которых нет очевидного взаимно однозначного соответствия в Rust. Эти различия часто мешают программистам C++ при переводе кода.
Иерархия приведений: четыре приведения C++ → эквиваленты в Rust
В C++ есть четыре именованных приведения. Rust заменяет их другими, более явными механизмами:
// Иерархия приведений C++
int i = static_cast<int>(3.14); // 1. Числовое / приведение вверх
Derived* d = dynamic_cast<Derived*>(base); // 2. Приведение вниз во время выполнения
int* p = const_cast<int*>(cp); // 3. Снятие const
auto* raw = reinterpret_cast<char*>(&obj); // 4. Переинтерпретация на уровне битов
| Приведение C++ | Эквивалент в Rust | Безопасность | Примечания |
|---|---|---|---|
static_cast (числовое) | Ключевое слово as | Безопасно, но может обрезать/переполнить | let i = 3.14_f64 as i32; — обрезает до 3 |
static_cast (числовое, проверяемое) | From/Into | Безопасно, проверяется на этапе компиляции | let i: i32 = 42_u8.into(); — только расширение |
static_cast (числовое, с возможной ошибкой) | TryFrom/TryInto | Безопасно, возвращает Result | let i: u8 = 300_u16.try_into()?; — вернёт Err |
dynamic_cast (приведение вниз) | match по перечислению / Any::downcast_ref | Безопасно | Сопоставление с образцом для перечислений; Any для трейт-объектов |
const_cast | Эквивалента нет | В безопасном Rust нет способа превратить & в &mut. Для внутренней изменяемости используйте Cell/RefCell | |
reinterpret_cast | std::mem::transmute | unsafe | Переинтерпретирует битовый шаблон. Почти всегда ошибочно — предпочитайте from_le_bytes() и т. п. |
#![allow(unused)]
fn main() {
// Эквиваленты в Rust:
// 1. Числовые приведения — предпочитайте From/Into вместо `as`
let widened: u32 = 42_u8.into(); // Расширение без ошибок — всегда предпочтительно
let truncated = 300_u16 as u8; // ⚠ Переполняется до 44! Тихая потеря данных
let checked: Result<u8, _> = 300_u16.try_into(); // Err — безопасное преобразование с возможной ошибкой
// 2. Приведение вниз: перечисление (предпочтительно) или Any (когда нужно стирание типа)
use std::any::Any;
fn handle_any(val: &dyn Any) {
if let Some(s) = val.downcast_ref::<String>() {
println!("Got string: {s}");
} else if let Some(n) = val.downcast_ref::<i32>() {
println!("Got int: {n}");
}
}
// 3. «const_cast» → внутренняя изменяемость (unsafe не нужен)
use std::cell::Cell;
struct Sensor {
read_count: Cell<u32>, // Изменяем через &self
}
impl Sensor {
fn read(&self) -> f64 {
self.read_count.set(self.read_count.get() + 1); // &self, а не &mut self
42.0
}
}
// 4. reinterpret_cast → transmute (почти никогда не нужно)
// Предпочитайте безопасные альтернативы:
let bytes: [u8; 4] = 0x12345678_u32.to_ne_bytes(); // ✅ Безопасно
let val = u32::from_ne_bytes(bytes); // ✅ Безопасно
// unsafe { std::mem::transmute::<u32, [u8; 4]>(val) } // ❌ Избегайте
}
Правило: в идиоматичном Rust
asдолжен встречаться редко (для расширения используйтеFrom/Into, для сужения —TryFrom/TryInto),transmute— исключительный случай, а уconst_castнет эквивалента, потому что типы с внутренней изменяемостью делают его ненужным.
Препроцессор → cfg, флаги функций и macro_rules!
C++ сильно опирается на препроцессор для условной компиляции, констант и генерации кода. Rust заменяет всё это полноценными возможностями языка.
#define-константы → const или const fn
// C++
#define MAX_RETRIES 5
#define BUFFER_SIZE (1024 * 64)
#define SQUARE(x) ((x) * (x)) // Макрос — текстовая подстановка, без проверки типов
#![allow(unused)]
fn main() {
// Rust — с проверкой типов, с областью видимости, без текстовой подстановки
const MAX_RETRIES: u32 = 5;
const BUFFER_SIZE: usize = 1024 * 64;
const fn square(x: u32) -> u32 { x * x } // Вычисляется на этапе компиляции
// Можно использовать в константных контекстах:
const AREA: u32 = square(12); // Вычисляется на этапе компиляции
static BUFFER: [u8; BUFFER_SIZE] = [0; BUFFER_SIZE];
}
#ifdef / #if → #[cfg()] и cfg!()
// C++
#ifdef DEBUG
log_verbose("Step 1 complete");
#endif
#if defined(LINUX) && !defined(ARM)
use_x86_path();
#else
use_generic_path();
#endif
#![allow(unused)]
fn main() {
// Rust — условная компиляция на основе атрибутов
#[cfg(debug_assertions)]
fn log_verbose(msg: &str) { eprintln!("[VERBOSE] {msg}"); }
#[cfg(not(debug_assertions))]
fn log_verbose(_msg: &str) { /* удаляется в релизной сборке */ }
// Комбинирование условий:
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
fn use_x86_path() { /* ... */ }
#[cfg(not(all(target_os = "linux", target_arch = "x86_64")))]
fn use_generic_path() { /* ... */ }
// Проверка во время выполнения (условие всё равно вычисляется на этапе компиляции, но его можно использовать в выражениях):
if cfg!(target_os = "windows") {
println!("Running on Windows");
}
}
Флаги функций в Cargo.toml
# Cargo.toml — замена #ifdef FEATURE_FOO
[features]
default = ["json"]
json = ["dep:serde_json"] # Необязательная зависимость
verbose-logging = [] # Флаг без дополнительной зависимости
gpu-support = ["dep:cuda-sys"] # Необязательная поддержка GPU
#![allow(unused)]
fn main() {
// Условный код на основе флагов функций:
#[cfg(feature = "json")]
pub fn parse_config(data: &str) -> Result<Config, Error> {
serde_json::from_str(data).map_err(Error::from)
}
#[cfg(feature = "verbose-logging")]
macro_rules! verbose {
($($arg:tt)*) => { eprintln!("[VERBOSE] {}", format!($($arg)*)); }
}
#[cfg(not(feature = "verbose-logging"))]
macro_rules! verbose {
($($arg:tt)*) => { }; // Компилируется в ничего
}
}
#define MACRO(x) → macro_rules!
// C++ — текстовая подстановка, печально известная своей подверженностью ошибкам
#define DIAG_CHECK(cond, msg) \
do { if (!(cond)) { log_error(msg); return false; } } while(0)
#![allow(unused)]
fn main() {
// Rust — гигиеничный, с проверкой типов, работает с синтаксическим деревом
macro_rules! diag_check {
($cond:expr, $msg:expr) => {
if !($cond) {
log_error($msg);
return Err(DiagError::CheckFailed($msg.to_string()));
}
};
}
fn run_test() -> Result<(), DiagError> {
diag_check!(temperature < 85.0, "GPU too hot");
diag_check!(voltage > 0.8, "Rail voltage too low");
Ok(())
}
}
| Препроцессор C++ | Эквивалент в Rust | Преимущество |
|---|---|---|
#define PI 3.14 | const PI: f64 = 3.14; | С типом, с областью видимости, видна отладчику |
#define MAX(a,b) ((a)>(b)?(a):(b)) | macro_rules! или обобщённая fn max<T: Ord> | Нет ошибок двойного вычисления |
#ifdef DEBUG | #[cfg(debug_assertions)] | Проверяется компилятором, нет риска опечатки |
#ifdef FEATURE_X | #[cfg(feature = "x")] | Cargo управляет флагами; учитывает зависимости |
#include "header.h" | mod module; + use module::Item; | Нет защиты от повторного включения, нет циклических включений |
#pragma once | Не нужно | Каждый файл .rs — модуль, подключается ровно один раз |
Заголовочные файлы и #include → модули и use
В C++ модель компиляции основана на текстовом включении:
// widget.h — каждая единица трансляции, использующая Widget, включает этот файл
#pragma once
#include <string>
#include <vector>
class Widget {
public:
Widget(std::string name);
void activate();
private:
std::string name_;
std::vector<int> data_;
};
// widget.cpp — отдельное определение
#include "widget.h"
Widget::Widget(std::string name) : name_(std::move(name)) {}
void Widget::activate() { /* ... */ }
В Rust нет заголовочных файлов, предварительных объявлений и защиты от повторного включения:
#![allow(unused)]
fn main() {
// src/widget.rs — объявление И определение в одном файле
pub struct Widget {
name: String, // По умолчанию приватное
data: Vec<i32>,
}
impl Widget {
pub fn new(name: String) -> Self {
Widget { name, data: Vec::new() }
}
pub fn activate(&self) { /* ... */ }
}
}
// src/main.rs — импорт по пути модуля
mod widget; // Сообщает компилятору, что нужно включить src/widget.rs
use widget::Widget;
fn main() {
let w = Widget::new("sensor".to_string());
w.activate();
}
| C++ | Rust | Почему лучше |
|---|---|---|
#include "foo.h" | mod foo; в родителе + use foo::Item; | Нет текстового включения, нет нарушений ODR |
#pragma once / защитные макросы | Не нужно | Каждый файл .rs — модуль, компилируется один раз |
| Предварительные объявления | Не нужны | Компилятор видит весь крейт; порядок не важен |
class Foo; (неполный тип) | Не нужно | Нет разделения на объявление и определение |
.h + .cpp для каждого класса | Один файл .rs | Нет ошибок рассогласования объявления и определения |
using namespace std; | use std::collections::HashMap; | Всегда явно — нет загрязнения глобального пространства имён |
Вложенные namespace a::b | Вложенные mod a { mod b { } } или a/b.rs | Структура файловой системы повторяет дерево модулей |
friend и контроль доступа → видимость модулей
В C++ friend даёт определённым классам или функциям доступ к приватным членам.
В Rust нет ключевого слова friend — вместо этого приватность ограничена модулем:
// C++
class Engine {
friend class Car; // Car может обращаться к приватным членам
int rpm_;
void set_rpm(int r) { rpm_ = r; }
public:
int rpm() const { return rpm_; }
};
// Rust — элементы одного модуля могут обращаться ко всем полям, `friend` не нужен
mod vehicle {
pub struct Engine {
rpm: u32, // Приватно для модуля (а не для структуры!)
}
impl Engine {
pub fn new() -> Self { Engine { rpm: 0 } }
pub fn rpm(&self) -> u32 { self.rpm }
}
pub struct Car {
engine: Engine,
}
impl Car {
pub fn new() -> Self { Car { engine: Engine::new() } }
pub fn accelerate(&mut self) {
self.engine.rpm = 3000; // ✅ Тот же модуль — прямой доступ к полю
}
pub fn rpm(&self) -> u32 {
self.engine.rpm // ✅ Тот же модуль — можно читать приватное поле
}
}
}
fn main() {
let mut car = vehicle::Car::new();
car.accelerate();
// car.engine.rpm = 9000; // ❌ Ошибка компиляции: `engine` приватно
println!("RPM: {}", car.rpm()); // ✅ Публичный метод Car
}
| Доступ в C++ | Эквивалент в Rust | Область действия |
|---|---|---|
private | (по умолчанию, без ключевого слова) | Доступно только внутри того же модуля |
protected | Прямого аналога нет | Используйте pub(super) для доступа из родительского модуля |
public | pub | Доступно везде |
friend class Foo | Поместить Foo в тот же модуль | Приватность на уровне модуля заменяет friend |
| — | pub(crate) | Видно внутри крейта, но не внешним зависимостям |
| — | pub(super) | Видно только родительскому модулю |
| — | pub(in crate::path) | Видно внутри конкретного поддерева модулей |
Ключевая мысль: приватность в C++ задаётся на уровне класса. Приватность в Rust — на уровне модуля. Это значит, что вы управляете доступом, выбирая, какие типы находятся в одном модуле — размещённые вместе типы имеют полный доступ к приватным полям друг друга.
volatile → атомарные операции и read_volatile/write_volatile
В C++ volatile говорит компилятору не оптимизировать чтения и записи — обычно это используется для регистров аппаратуры, отображённых в память. В Rust нет ключевого слова volatile.
// C++: volatile для аппаратных регистров
volatile uint32_t* const GPIO_REG = reinterpret_cast<volatile uint32_t*>(0x4002'0000);
*GPIO_REG = 0x01; // Запись не оптимизируется
uint32_t val = *GPIO_REG; // Чтение не оптимизируется
#![allow(unused)]
fn main() {
// Rust: явные volatile-операции — только в unsafe-коде
use std::ptr;
const GPIO_REG: *mut u32 = 0x4002_0000 as *mut u32;
// SAFETY: GPIO_REG — валидный адрес ввода-вывода, отображённый в память.
unsafe {
ptr::write_volatile(GPIO_REG, 0x01); // Запись не оптимизируется
let val = ptr::read_volatile(GPIO_REG); // Чтение не оптимизируется
}
}
Для совместно используемого состояния в конкурентном коде (другое частое применение volatile в C++) Rust использует атомарные типы:
// C++: volatile НЕДОСТАТОЧЕН для потокобезопасности (распространённая ошибка!)
volatile bool stop_flag = false; // ❌ Гонка данных — неопределённое поведение в C++11+
// Правильно в C++:
std::atomic<bool> stop_flag{false};
#![allow(unused)]
fn main() {
// Rust: атомарные типы — единственный способ разделять изменяемое состояние между потоками
use std::sync::atomic::{AtomicBool, Ordering};
static STOP_FLAG: AtomicBool = AtomicBool::new(false);
// Из другого потока:
STOP_FLAG.store(true, Ordering::Release);
// Проверка:
if STOP_FLAG.load(Ordering::Acquire) {
println!("Stopping");
}
}
| Применение в C++ | Эквивалент в Rust | Примечания |
|---|---|---|
volatile для аппаратных регистров | ptr::read_volatile / ptr::write_volatile | Требует unsafe — корректно для MMIO |
volatile для сигнализации между потоками | AtomicBool / AtomicU32 и т. д. | volatile в C++ для этого тоже неверен! |
std::atomic<T> | std::sync::atomic::AtomicT | Та же семантика, те же порядки доступа |
std::atomic<T>::load(memory_order_acquire) | AtomicT::load(Ordering::Acquire) | Соответствие 1:1 |
Переменные static → static, const, LazyLock, OnceLock
Базовые static и const
// C++
const int MAX_RETRIES = 5; // Константа времени компиляции
static std::string CONFIG_PATH = "/etc/app"; // Статическая инициализация — порядок не определён!
#![allow(unused)]
fn main() {
// Rust
const MAX_RETRIES: u32 = 5; // Константа времени компиляции, встраивается
static CONFIG_PATH: &str = "/etc/app"; // Время жизни 'static, фиксированный адрес
}
Печально известная проблема порядка статической инициализации
В C++ есть хорошо известная проблема: глобальные конструкторы в разных единицах трансляции выполняются в неопределённом порядке. Rust полностью обходит её — значения static должны быть константами времени компиляции (без конструкторов).
Для глобальных переменных, инициализируемых во время выполнения, используйте LazyLock (Rust 1.80+) или OnceLock:
#![allow(unused)]
fn main() {
use std::sync::LazyLock;
// Аналог C++ `static std::regex` — инициализируется при первом обращении, потокобезопасно
static CONFIG_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"^[a-z]+_diag$").expect("invalid regex")
});
fn is_valid_diag(name: &str) -> bool {
CONFIG_REGEX.is_match(name) // Первый вызов инициализирует; последующие работают быстро
}
}
#![allow(unused)]
fn main() {
use std::sync::OnceLock;
// OnceLock: инициализируется один раз, может быть задан из данных времени выполнения
static DB_CONN: OnceLock<String> = OnceLock::new();
fn init_db(connection_string: &str) {
DB_CONN.set(connection_string.to_string())
.expect("DB_CONN already initialized");
}
fn get_db() -> &'static str {
DB_CONN.get().expect("DB not initialized")
}
}
| C++ | Rust | Примечания |
|---|---|---|
const int X = 5; | const X: i32 = 5; | Оба — на этапе компиляции. В Rust нужна аннотация типа |
constexpr int X = 5; | const X: i32 = 5; | const в Rust всегда constexpr |
static int count = 0; (на уровне файла) | static COUNT: AtomicI32 = AtomicI32::new(0); | Изменяемые статики требуют unsafe или атомарных типов |
static std::string s = "hi"; | static S: &str = "hi"; или LazyLock<String> | Для простых случаев нет конструктора времени выполнения |
static MyObj obj; (сложная инициализация) | static OBJ: LazyLock<MyObj> = LazyLock::new(|| { ... }); | Потокобезопасно, ленивая инициализация, нет проблем с порядком |
thread_local | thread_local! { static X: Cell<u32> = Cell::new(0); } | Та же семантика |
constexpr → const fn
constexpr в C++ помечает функции и переменные для вычисления на этапе компиляции. Rust
использует const fn и const для той же цели:
// C++
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
constexpr int val = factorial(5); // Вычисляется на этапе компиляции → 120
#![allow(unused)]
fn main() {
// Rust
const fn factorial(n: u32) -> u32 {
if n <= 1 { 1 } else { n * factorial(n - 1) }
}
const VAL: u32 = factorial(5); // Вычисляется на этапе компиляции → 120
// Также работает в размерах массивов и в шаблонах match:
const LOOKUP: [u32; 5] = [factorial(1), factorial(2), factorial(3),
factorial(4), factorial(5)];
}
| C++ | Rust | Примечания |
|---|---|---|
constexpr int f() | const fn f() -> i32 | Та же цель — вычисляемая на этапе компиляции |
Переменная constexpr | Переменная const | const в Rust всегда вычисляется на этапе компиляции |
consteval (C++20) | Эквивалента нет | const fn может выполняться и во время выполнения |
if constexpr (C++17) | Эквивалента нет (используйте cfg! или обобщения) | Специализация трейтов покрывает часть случаев |
constinit (C++20) | static с константным инициализатором | static в Rust по умолчанию должен инициализироваться константой |
Текущие ограничения
const fn(стабилизировано в Rust 1.82):
- Нельзя вызывать методы трейтов (нельзя вызвать
.len()уVecв константном контексте)- Нет выделения памяти в куче (
Box::new,Vec::newне являютсяconst)Нет арифметики с плавающей точкой— стабилизирована в Rust 1.82- Нельзя использовать циклы
for(используйте рекурсию илиwhileс ручным индексом)
SFINAE и enable_if → ограничения трейтами и предложения where
В C++ SFINAE (Substitution Failure Is Not An Error, «неудача подстановки — не ошибка») — механизм условного обобщённого программирования. Он мощен, но печально известен нечитаемостью. Rust полностью заменяет его ограничениями трейтами (trait bounds):
// C++: условная функция на основе SFINAE (до C++20)
template<typename T,
std::enable_if_t<std::is_integral_v<T>, int> = 0>
T double_it(T val) { return val * 2; }
template<typename T,
std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
T double_it(T val) { return val * 2.0; }
// Концепты C++20 — чище, но всё ещё многословны:
template<std::integral T>
T double_it(T val) { return val * 2; }
#![allow(unused)]
fn main() {
// Rust: ограничения трейтами — читаемо, компонуемо, отличные сообщения об ошибках
use std::ops::Mul;
fn double_it<T: Mul<Output = T> + From<u8>>(val: T) -> T {
val * T::from(2)
}
// Или с предложением where для сложных ограничений:
fn process<T>(val: T) -> String
where
T: std::fmt::Display + Clone + Send,
{
format!("Processing: {}", val)
}
// Условное поведение через отдельные реализации (замена перегрузок на основе SFINAE):
trait Describable {
fn describe(&self) -> String;
}
impl Describable for u32 {
fn describe(&self) -> String { format!("integer: {self}") }
}
impl Describable for f64 {
fn describe(&self) -> String { format!("float: {self:.2}") }
}
}
| Шаблонное метапрограммирование C++ | Эквивалент в Rust | Читаемость |
|---|---|---|
std::enable_if_t<cond> | where T: Trait | 🟢 Понятно, как обычный английский |
std::is_integral_v<T> | Ограничение числовым трейтом или конкретными типами | 🟢 Без суффиксов _v / _t |
| Наборы перегрузок SFINAE | Отдельные блоки impl Trait for ConcreteType | 🟢 Каждая реализация самостоятельна |
if constexpr (std::is_same_v<T, int>) | Специализация через реализации трейтов | 🟢 Диспетчеризация на этапе компиляции |
| Концепт C++20 | trait | 🟢 Почти одинаковый смысл |
Клауза requires | Предложение where | 🟢 Та же позиция, похожий синтаксис |
| Компиляция падает глубоко внутри шаблона | Компиляция падает в месте вызова с несоответствием трейта | 🟢 Без каскадов ошибок на 200 строк |
Ключевая мысль: концепты C++20 — самое близкое к трейтам Rust. Если вы знакомы с концептами C++20, думайте о трейтах Rust как о концептах, которые являются полноценной возможностью языка с версии 1.0 и имеют согласованную модель реализации (реализации трейтов), а не утиной типизации.
std::function → указатели на функции, impl Fn и Box<dyn Fn>
std::function<R(Args...)> в C++ — это вызываемый объект со стёртым типом. В Rust есть три варианта
с разными компромиссами:
// C++: универсальное решение (в куче, со стёртым типом)
#include <functional>
std::function<int(int)> make_adder(int n) {
return [n](int x) { return x + n; };
}
#![allow(unused)]
fn main() {
// Вариант 1 в Rust: указатель на функцию — просто, без захватов, без выделения памяти
fn add_one(x: i32) -> i32 { x + 1 }
let f: fn(i32) -> i32 = add_one;
println!("{}", f(5)); // 6
// Вариант 2 в Rust: impl Fn — мономорфизация, без накладных расходов, может захватывать
fn apply(val: i32, f: impl Fn(i32) -> i32) -> i32 { f(val) }
let n = 10;
let result = apply(5, |x| x + n); // Замыкание захватывает `n`
// Вариант 3 в Rust: Box<dyn Fn> — стирание типа, выделение в куче (как std::function)
fn make_adder(n: i32) -> Box<dyn Fn(i32) -> i32> {
Box::new(move |x| x + n)
}
let adder = make_adder(10);
println!("{}", adder(5)); // 15
// Хранение разнородных вызываемых объектов (как vector<function<int(int)>>):
let callbacks: Vec<Box<dyn Fn(i32) -> i32>> = vec![
Box::new(|x| x + 1),
Box::new(|x| x * 2),
Box::new(make_adder(100)),
];
for cb in &callbacks {
println!("{}", cb(5)); // 6, 10, 105
}
}
| Когда использовать | Аналог в C++ | Выбор в Rust |
|---|---|---|
| Функция верхнего уровня без захватов | Указатель на функцию | fn(Args) -> Ret |
| Обобщённая функция, принимающая вызываемые объекты | Параметр шаблона | impl Fn(Args) -> Ret (статическая диспетчеризация) |
| Ограничение трейтом в обобщениях | template<typename F> | F: Fn(Args) -> Ret |
| Хранимый вызываемый объект со стёртым типом | std::function<R(Args)> | Box<dyn Fn(Args) -> Ret> |
| Обратный вызов, изменяющий состояние | std::function с изменяемой лямбдой | Box<dyn FnMut(Args) -> Ret> |
| Одноразовый обратный вызов (потребляется) | std::function (перемещённый) | Box<dyn FnOnce(Args) -> Ret> |
Замечание о производительности:
impl Fnне имеет накладных расходов (мономорфизация, как шаблон C++).Box<dyn Fn>имеет те же накладные расходы, что иstd::function(vtable и выделение в куче). Предпочитайтеimpl Fn, если не нужно хранить разнородные вызываемые объекты.
Соответствие контейнеров: STL C++ → std::collections Rust
| Контейнер STL C++ | Эквивалент в Rust | Примечания |
|---|---|---|
std::vector<T> | Vec<T> | Почти идентичный API. Rust проверяет границы по умолчанию |
std::array<T, N> | [T; N] | Массив фиксированного размера на стеке |
std::deque<T> | std::collections::VecDeque<T> | Кольцевой буфер. Эффективные push/pop с обоих концов |
std::list<T> | std::collections::LinkedList<T> | В Rust используется редко — Vec почти всегда быстрее |
std::forward_list<T> | Эквивалента нет | Используйте Vec или VecDeque |
std::unordered_map<K, V> | std::collections::HashMap<K, V> | По умолчанию использует SipHash (устойчив к DoS) |
std::map<K, V> | std::collections::BTreeMap<K, V> | B-дерево; ключи отсортированы; требуется K: Ord |
std::unordered_set<T> | std::collections::HashSet<T> | Требуется T: Hash + Eq |
std::set<T> | std::collections::BTreeSet<T> | Отсортированное множество; требуется T: Ord |
std::priority_queue<T> | std::collections::BinaryHeap<T> | Max-куча по умолчанию (как в C++) |
std::stack<T> | Vec<T> с .push() / .pop() | Отдельный тип стека не нужен |
std::queue<T> | VecDeque<T> с .push_back() / .pop_front() | Отдельный тип очереди не нужен |
std::string | String | Гарантированный UTF-8, без завершающего нуля |
std::string_view | &str | Заимствованный срез UTF-8 |
std::span<T> (C++20) | &[T] / &mut [T] | Срезы Rust — полноценный тип с версии 1.0 |
std::tuple<A, B, C> | (A, B, C) | Встроенный синтаксис, поддерживает деструктуризацию |
std::pair<A, B> | (A, B) | Просто кортеж из двух элементов |
std::bitset<N> | Эквивалента в std нет | Используйте крейт bitvec или [u8; N/8] |
Ключевые различия:
- Для
HashMap/HashSetв Rust требуетсяK: Hash + Eq— компилятор проверяет это на уровне типов, в отличие от C++, где использование ключа без хеширования даёт ошибку шаблона глубоко внутри STL - Индексация
Vec(v[i]) по умолчанию вызывает panic при выходе за границы. Используйте.get(i)дляOption<&T>или итераторы, чтобы полностью избежать проверок границ - Нет
std::multimapиstd::multiset— используйтеHashMap<K, Vec<V>>илиBTreeMap<K, Vec<V>>
Безопасность исключений → безопасность при panic
C++ определяет три уровня безопасности исключений (гарантии Абрахамса):
| Уровень C++ | Значение | Эквивалент в Rust |
|---|---|---|
| No-throw | Функция никогда не выбрасывает исключение | Функция никогда не вызывает panic (возвращает Result) |
| Strong (фиксация или откат) | Если выброшено исключение, состояние не меняется | Модель владения делает это естественным — если ? досрочно возвращает управление, частично построенные значения уничтожаются |
| Basic | Если выброшено исключение, инварианты сохраняются | Поведение Rust по умолчанию — Drop выполняется, утечек нет |
Как модель владения помогает
#![allow(unused)]
fn main() {
// Сильная гарантия бесплатно — если file.write() завершится ошибкой, config не меняется
fn update_config(config: &mut Config, path: &str) -> Result<(), Error> {
let new_data = fetch_from_network()?; // Err → ранний возврат, config не тронут
let validated = validate(new_data)?; // Err → ранний возврат, config не тронут
*config = validated; // Выполняется только при успехе (фиксация)
Ok(())
}
}
В C++ для сильной гарантии нужен ручной откат или идиома copy-and-swap. В Rust
распространение через ? даёт сильную гарантию по умолчанию для большинства кода.
catch_unwind — аналог catch(...) в Rust
#![allow(unused)]
fn main() {
use std::panic;
// Перехват panic (как catch(...) в C++) — нужно редко
let result = panic::catch_unwind(|| {
// Код, который может вызвать panic
let v = vec![1, 2, 3];
v[10] // Panic! (выход за границы индекса)
});
match result {
Ok(val) => println!("Got: {val}"),
Err(_) => eprintln!("Caught a panic — cleaned up"),
}
}
UnwindSafe — отметка типов как безопасных при panic
#![allow(unused)]
fn main() {
use std::panic::UnwindSafe;
// Типы за &mut по умолчанию НЕ UnwindSafe — panic мог оставить их
// в частично изменённом состоянии
fn safe_execute<F: FnOnce() + UnwindSafe>(f: F) {
let _ = std::panic::catch_unwind(f);
}
// Используйте AssertUnwindSafe, чтобы переопределить, когда вы проверили код:
use std::panic::AssertUnwindSafe;
let mut data = vec![1, 2, 3];
let _ = std::panic::catch_unwind(AssertUnwindSafe(|| {
data.push(4);
}));
}
| Шаблон исключений C++ | Эквивалент в Rust |
|---|---|
throw MyException() | return Err(MyError::...) (предпочтительно) или panic!("...") |
try { } catch (const E& e) | match result { Ok(v) => ..., Err(e) => ... } или ? |
catch (...) | std::panic::catch_unwind(...) |
noexcept | -> Result<T, E> (ошибки — это значения, а не исключения) |
| Очистка RAII при раскрутке стека | Drop::drop() выполняется при раскрутке panic |
std::uncaught_exceptions() | std::thread::panicking() |
Флаг компиляции -fno-exceptions | panic = "abort" в секции [profile] файла Cargo.toml |
Главное: в Rust большинство кода использует
Result<T, E>вместо исключений, что делает пути ошибок явными и компонуемыми.panic!зарезервирован для ошибок в программе (например, при сбоеassert!), а не для рутинных ошибок. Поэтому «безопасность исключений» во многом перестаёт быть проблемой — система владения автоматически занимается очисткой.
Шаблоны миграции с C++ на Rust
Краткий справочник: соответствие идиом C++ → Rust
| Шаблон C++ | Идиома Rust | Примечания |
|---|---|---|
class Derived : public Base | enum Variant { A {...}, B {...} } | Для закрытых наборов предпочитайте перечисления |
virtual void method() = 0 | trait MyTrait { fn method(&self); } | Для открытых/расширяемых интерфейсов |
dynamic_cast<Derived*>(ptr) | match value { Variant::A(data) => ..., } | Исчерпывающе, без ошибок во время выполнения |
vector<unique_ptr<Base>> | Vec<Box<dyn Trait>> | Только когда действительно нужен полиморфизм |
shared_ptr<T> | Rc<T> или Arc<T> | Сначала рассмотрите Box<T> или владеющие значения |
enable_shared_from_this<T> | Паттерн арены (Vec<T> + индексы) | Полностью устраняет циклы ссылок |
Base* m_pFramework в каждом классе | fn execute(&mut self, ctx: &mut Context) | Передавайте контекст, не храните указатели |
try { } catch (...) { } | match result { Ok(v) => ..., Err(e) => ... } | Или ? для распространения |
std::optional<T> | Option<T> | Требуется match, нельзя забыть None |
Параметр const std::string& | Параметр &str | Принимает и String, и &str |
enum class Foo { A, B, C } | enum Foo { A, B, C } | Перечисления Rust могут также хранить данные |
auto x = std::move(obj) | let x = obj; | Перемещение по умолчанию, std::move не нужен |
| CMake + make + линтер | cargo build / test / clippy / fmt | Один инструмент для всего |
Стратегия миграции
- Начните с типов данных: сначала переводите структуры и перечисления — это заставляет думать о владении
- Превращайте фабрики в перечисления: если фабрика создаёт разные производные типы, скорее всего, это должно быть
enum+match - Превращайте божественные объекты в компонуемые структуры: группируйте связанные поля в сфокусированные структуры
- Заменяйте указатели заимствованиями: переводите хранимые указатели
Base*в заимствования с ограниченным временем жизни&'a T - Используйте
Box<dyn Trait>экономно: только для систем плагинов и моков в тестах - Доверьтесь компилятору: сообщения об ошибках Rust отличны — читайте их внимательно
19. Макросы Rust: от препроцессора к метапрограммированию
Макросы Rust: от препроцессора к метапрограммированию
Что вы узнаете: как работают макросы Rust, когда использовать их вместо функций или обобщений и как они заменяют препроцессор C/C++. К концу главы вы сможете писать собственные макросы
macro_rules!и понимать, что скрывается за#[derive(Debug)].
Макросы — одна из первых вещей, с которой вы сталкиваетесь в Rust (println!("hello") в первой же строке), но одна из последних, что объясняют большинство курсов. Эта глава восполняет этот пробел.
Зачем нужны макросы
Функции и обобщения покрывают большую часть повторного использования кода в Rust. Макросы закрывают пробелы, до которых не дотягивается система типов:
| Потребность | Функция/обобщение? | Макрос? | Почему |
|---|---|---|---|
| Вычислить значение | ✅ fn max<T: Ord>(a: T, b: T) -> T | — | Это делает система типов |
| Принимать переменное число аргументов | ❌ В Rust нет вариативных функций | ✅ println!("{} {}", a, b) | Макросы принимают любое число токенов |
Генерировать однотипные блоки impl | ❌ Одними обобщениями не получится | ✅ macro_rules! | Макросы генерируют код на этапе компиляции |
| Выполнять код на этапе компиляции | ❌ const fn ограничен | ✅ Процедурные макросы | Полноценный код Rust выполняется на этапе компиляции |
| Условно включать код | ❌ | ✅ #[cfg(...)] | Макросы-атрибуты управляют компиляцией |
Если вы пришли из C/C++, думайте о макросах как о единственной правильной замене препроцессора — с той разницей, что они работают с синтаксическим деревом, а не с сырым текстом, поэтому они гигиеничны (нет случайных коллизий имён) и учитывают типы.
Для разработчиков на C: макросы Rust полностью заменяют
#define. Текстового препроцессора нет. Полное соответствие препроцессора и Rust см. в ch18.
Декларативные макросы с macro_rules!
Декларативные макросы (также называемые «макросами по примеру») — самая распространённая форма макросов в Rust. Они используют сопоставление с образцом по синтаксису, похожее на match по значениям.
Базовый синтаксис
macro_rules! say_hello {
() => {
println!("Hello!");
};
}
fn main() {
say_hello!(); // Раскрывается в: println!("Hello!");
}
! после имени — это то, что сообщает (и вам, и компилятору), что это вызов макроса.
Сопоставление с образцом по аргументам
Макросы сопоставляют деревья токенов с помощью спецификаторов фрагментов:
macro_rules! greet {
// Образец 1: без аргументов
() => {
println!("Hello, world!");
};
// Образец 2: один аргумент-выражение
($name:expr) => {
println!("Hello, {}!", $name);
};
}
fn main() {
greet!(); // "Hello, world!"
greet!("Rust"); // "Hello, Rust!"
}
Справочник спецификаторов фрагментов
| Спецификатор | Соответствует | Пример |
|---|---|---|
$x:expr | Любому выражению | 42, a + b, foo() |
$x:ty | Типу | i32, Vec<String>, &str |
$x:ident | Идентификатору | foo, my_var |
$x:pat | Образцу | Some(x), _, (a, b) |
$x:stmt | Оператору | let x = 5; |
$x:block | Блоку | { println!("hi"); 42 } |
$x:literal | Литералу | 42, "hello", true |
$x:tt | Одному дереву токенов | Что угодно — универсальный шаблон |
$x:item | Элементу (fn, struct, impl и т. д.) | fn foo() {} |
Повторение — ключевая возможность
Макросы C/C++ не умеют циклов. Макросы Rust умеют повторять образцы:
macro_rules! make_vec {
// Сопоставляем ноль или более выражений, разделённых запятыми
( $( $element:expr ),* ) => {
{
let mut v = Vec::new();
$( v.push($element); )* // Повторяем для каждого сопоставленного элемента
v
}
};
}
fn main() {
let v = make_vec![1, 2, 3, 4, 5];
println!("{v:?}"); // [1, 2, 3, 4, 5]
}
Синтаксис $( ... ),* означает «сопоставить ноль или более таких образцов, разделённых запятыми». Конструкция $( ... )* в раскрытии повторяет тело один раз для каждого совпадения.
Именно так реализован
vec![]в стандартной библиотеке. Реальный исходный код:#![allow(unused)] fn main() { macro_rules! vec { () => { Vec::new() }; ($elem:expr; $n:expr) => { vec::from_elem($elem, $n) }; ($($x:expr),+ $(,)?) => { <[_]>::into_vec(Box::new([$($x),+])) }; } }Конструкция
$(,)?в конце разрешает необязательную завершающую запятую.
Операторы повторения
| Оператор | Значение | Пример |
|---|---|---|
$( ... )* | Ноль или более | vec![], vec![1], vec![1, 2, 3] |
$( ... )+ | Один или более | Требуется хотя бы один элемент |
$( ... )? | Ноль или один | Необязательный элемент |
Практический пример: конструктор hashmap!
В стандартной библиотеке есть vec![], но нет hashmap!{}. Давайте создадим его:
macro_rules! hashmap {
( $( $key:expr => $value:expr ),* $(,)? ) => {
{
let mut map = std::collections::HashMap::new();
$( map.insert($key, $value); )*
map
}
};
}
fn main() {
let scores = hashmap! {
"Alice" => 95,
"Bob" => 87,
"Carol" => 92, // Завершающая запятая допустима благодаря $(,)?
};
println!("{scores:?}");
}
Практический пример: макрос проверки диагностики
Шаблон, распространённый во встраиваемом и диагностическом коде, — проверить условие и вернуть ошибку:
#![allow(unused)]
fn main() {
use thiserror::Error;
#[derive(Error, Debug)]
enum DiagError {
#[error("Check failed: {0}")]
CheckFailed(String),
}
macro_rules! diag_check {
($cond:expr, $msg:expr) => {
if !($cond) {
return Err(DiagError::CheckFailed($msg.to_string()));
}
};
}
fn run_diagnostics(temp: f64, voltage: f64) -> Result<(), DiagError> {
diag_check!(temp < 85.0, "GPU too hot");
diag_check!(voltage > 0.8, "Rail voltage too low");
diag_check!(voltage < 1.5, "Rail voltage too high");
println!("All checks passed");
Ok(())
}
}
Сравнение с C/C++:
// Препроцессор C — текстовая подстановка, без проверки типов, без гигиены #define DIAG_CHECK(cond, msg) \ do { if (!(cond)) { log_error(msg); return -1; } } while(0)Версия на Rust возвращает корректный тип
Result, не рискует двойным вычислением, а компилятор проверяет, что$condдействительно является выражением типаbool.
Гигиена: почему макросы Rust безопасны
Ошибки макросов C/C++ часто возникают из-за коллизий имён:
// C: опасно — `x` может затенить `x` вызывающего кода
#define SQUARE(x) ((x) * (x))
int x = 5;
int result = SQUARE(x++); // Неопределённое поведение: x инкрементируется дважды!
Макросы Rust гигиеничны — переменные, созданные внутри макроса, не утекают наружу:
macro_rules! make_x {
() => {
let x = 42; // Этот `x` ограничен раскрытием макроса
};
}
fn main() {
let x = 10;
make_x!();
println!("{x}"); // Выводит 10, а не 42 — гигиена предотвращает коллизию
}
Компилятор считает x макроса и x вызывающего кода разными переменными, даже несмотря на одинаковые имена. С препроцессором C это невозможно.
Распространённые макросы стандартной библиотеки
Вы используете их с первой главы — вот что они делают на самом деле:
| Макрос | Что делает | Раскрывается в (упрощённо) |
|---|---|---|
println!("{}", x) | Форматирует и выводит в stdout с переводом строки | std::io::_print(format_args!(...)) |
eprintln!("{}", x) | Выводит в stderr с переводом строки | То же, но в stderr |
format!("{}", x) | Форматирует в String | Выделяет память и возвращает String |
vec![1, 2, 3] | Создаёт Vec с элементами | Vec::from([1, 2, 3]) (примерно) |
todo!() | Отмечает незавершённый код | panic!("not yet implemented") |
unimplemented!() | Отмечает намеренно не реализованный код | panic!("not implemented") |
unreachable!() | Отмечает код, недостижимость которого компилятор не может доказать | panic!("unreachable") |
assert!(cond) | Вызывает panic, если условие ложно | if !cond { panic!(...) } |
assert_eq!(a, b) | Вызывает panic, если значения не равны | При ошибке показывает оба значения |
dbg!(expr) | Выводит выражение и его значение в stderr, возвращает значение | eprintln!("[file:line] expr = {:#?}", &expr); expr |
include_str!("file.txt") | Встраивает содержимое файла как &str на этапе компиляции | Читает файл во время компиляции |
include_bytes!("data.bin") | Встраивает содержимое файла как &[u8] на этапе компиляции | Читает файл во время компиляции |
cfg!(condition) | Условие времени компиляции как bool | true или false в зависимости от цели |
env!("VAR") | Читает переменную окружения на этапе компиляции | Компиляция завершается ошибкой, если её нет |
concat!("a", "b") | Объединяет литералы на этапе компиляции | "ab" |
dbg! — макрос для отладки, которым вы будете пользоваться каждый день
fn factorial(n: u32) -> u32 {
if dbg!(n <= 1) { // Выводит: [src/main.rs:2] n <= 1 = false
dbg!(1) // Выводит: [src/main.rs:3] 1 = 1
} else {
dbg!(n * factorial(n - 1)) // Выводит промежуточные значения
}
}
fn main() {
dbg!(factorial(4)); // Выводит все рекурсивные вызовы с файлом и строкой
}
dbg! возвращает значение, которое оборачивает, поэтому его можно вставить куда угодно, не меняя поведение программы. Он выводит в stderr (а не в stdout), поэтому не мешает выводу программы. Перед коммитом удаляйте все вызовы dbg!.
Синтаксис строк формата
Поскольку println!, format!, eprintln! и write! используют один и тот же механизм форматирования, вот краткий справочник:
#![allow(unused)]
fn main() {
let name = "sensor";
let value = 3.14159;
let count = 42;
println!("{name}"); // Переменная по имени (Rust 1.58+)
println!("{}", name); // Позиционно
println!("{value:.2}"); // 2 знака после запятой: "3.14"
println!("{count:>10}"); // Выравнивание по правому краю, ширина 10: " 42"
println!("{count:0>10}"); // Дополнение нулями: "0000000042"
println!("{count:#06x}"); // Шестнадцатеричный с префиксом: "0x002a"
println!("{count:#010b}"); // Двоичный с префиксом: "0b00101010"
println!("{value:?}"); // Формат Debug
println!("{value:#?}"); // Формат Debug с красивым выводом
}
Для разработчиков на C: думайте об этом как о типобезопасном
printf— компилятор проверяет, что{:.2}применяется к числу с плавающей точкой, а не к строке. Ошибок несоответствия%s/%dне бывает.Для разработчиков на C++: это заменяет
std::cout << std::fixed << std::setprecision(2) << valueодной читаемой строкой формата.
Макросы derive
Вы видели #[derive(...)] почти у каждой структуры в этой книге:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
struct Point {
x: f64,
y: f64,
}
}
#[derive(Debug)] — это макрос derive, особый вид процедурного макроса, который автоматически генерирует реализации трейтов. Вот что он создаёт (упрощённо):
#![allow(unused)]
fn main() {
// То, что #[derive(Debug)] генерирует для Point:
impl std::fmt::Debug for Point {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Point")
.field("x", &self.x)
.field("y", &self.y)
.finish()
}
}
}
Без #[derive(Debug)] вам пришлось бы писать такой блок impl вручную для каждой структуры.
Распространённые производные трейты
| Derive | Что генерирует | Когда использовать |
|---|---|---|
Debug | Форматирование {:?} | Почти всегда — позволяет выводить для отладки |
Clone | Метод .clone() | Когда нужно дублировать значения |
Copy | Неявное копирование при присваивании | Небольшие типы только на стеке (целые, [f64; 3]) |
PartialEq / Eq | Операторы == и != | Когда нужно сравнение на равенство |
PartialOrd / Ord | Операторы <, >, <=, >= | Когда нужен порядок |
Hash | Хеширование для ключей HashMap/HashSet | Типы, используемые как ключи словаря |
Default | Конструктор Type::default() | Типы с разумными нулевыми/пустыми значениями |
serde::Serialize / Deserialize | Сериализация в JSON/TOML и т. д. | Типы данных, пересекающие границы API |
Дерево решений по derive
Стоит ли выводить этот трейт?
│
├── Содержит ли мой тип только типы, которые реализуют трейт?
│ ├── Да → #[derive] сработает
│ └── Нет → Напишите реализацию вручную (или пропустите)
│
└── Будут ли пользователи моего типа разумно ожидать такого поведения?
├── Да → Выводите (Debug, Clone, PartialEq почти всегда разумны)
└── Нет → Не выводите (например, не выводите Copy для типа с файловым дескриптором)
Сравнение с C++:
#[derive(Clone)]похож на автоматическую генерацию корректного конструктора копирования.#[derive(PartialEq)]похож на автоматически сгенерированныйoperator==, который сравнивает каждое поле — то, что оператор «космического корабля»= defaultиз C++20 наконец предоставляет.
Макросы-атрибуты
Макросы-атрибуты преобразуют элемент, к которому они прикреплены. Некоторые вы уже использовали:
#![allow(unused)]
fn main() {
#[test] // Помечает функцию как тест
fn test_addition() {
assert_eq!(2 + 2, 4);
}
#[cfg(target_os = "linux")] // Условно включает эту функцию
fn linux_only() { /* ... */ }
#[derive(Debug)] // Генерирует реализацию Debug
struct MyType { /* ... */ }
#[allow(dead_code)] // Подавляет предупреждение компилятора
fn unused_helper() { /* ... */ }
#[must_use] // Предупреждает, если возвращаемое значение отброшено
fn compute_checksum(data: &[u8]) -> u32 { /* ... */ }
}
Распространённые встроенные атрибуты:
| Атрибут | Назначение |
|---|---|
#[test] | Пометить как тестовую функцию |
#[cfg(...)] | Условная компиляция |
#[derive(...)] | Автоматическая генерация реализаций трейтов |
#[allow(...)] / #[deny(...)] / #[warn(...)] | Управление уровнями линтера |
#[must_use] | Предупреждать о неиспользованных возвращаемых значениях |
#[inline] / #[inline(always)] | Подсказка компилятору встроить функцию |
#[repr(C)] | Использовать раскладку памяти, совместимую с C (для FFI) |
#[no_mangle] | Не искажать имя символа (для FFI) |
#[deprecated] | Пометить как устаревший с необязательным сообщением |
Для разработчиков на C/C++: атрибуты заменяют смесь директив препроцессора (
#pragma,__attribute__((...))) и расширений, специфичных для компилятора. Они входят в грамматику языка, а не являются приклеенными расширениями.
Процедурные макросы (концептуальный обзор)
Процедурные макросы («proc macros») — это макросы, написанные как отдельные программы на Rust, которые выполняются на этапе компиляции и генерируют код. Они мощнее, чем macro_rules!, но и сложнее.
Существует три вида:
| Вид | Синтаксис | Пример | Что делает |
|---|---|---|---|
| Функциональный | my_macro!(...) | sql!(SELECT * FROM users) | Разбирает пользовательский синтаксис, генерирует код Rust |
| Derive | #[derive(MyTrait)] | #[derive(Serialize)] | Генерирует реализацию трейта по определению структуры |
| Атрибут | #[my_attr] | #[tokio::main], #[instrument] | Преобразует помеченный элемент |
Вы уже используете процедурные макросы
#[derive(Error)]изthiserror— генерирует реализацииDisplayиFromдля перечислений ошибок#[derive(Serialize, Deserialize)]изserde— генерирует код сериализации#[tokio::main]— превращаетasync fn main()в настройку среды выполнения + block_on#[test]— регистрируется тестовым харнессом (встроенный процедурный макрос)
Когда стоит писать собственный процедурный макрос
Скорее всего, в ходе этого курса писать процедурные макросы не понадобится. Они полезны, когда:
- Нужно анализировать поля структур или варианты перечислений на этапе компиляции (макросы derive)
- Вы создаёте предметно-ориентированный язык (функциональные макросы)
- Нужно преобразовывать сигнатуры функций (макросы-атрибуты)
Для большинства кода достаточно macro_rules! или обычных функций.
Сравнение с C++: процедурные макросы выполняют ту роль, которую в C++ играют генераторы кода, метапрограммирование на шаблонах и внешние инструменты вроде
protoc. Разница в том, что процедурные макросы — часть конвейера сборки cargo: никаких внешних шагов сборки, никаких пользовательских команд CMake.
Когда что использовать: макросы, функции или обобщения
Нужно генерировать код?
│
├── Нет → Используйте функцию или обобщённую функцию
│ (проще, лучшие сообщения об ошибках, поддержка IDE)
│
└── Да ─┬── Переменное число аргументов?
│ └── Да → macro_rules! (например, println!, vec!)
│
├── Однотипные блоки impl для многих типов?
│ └── Да → macro_rules! с повторением
│
├── Нужно анализировать поля структуры?
│ └── Да → Макрос derive (процедурный макрос)
│
├── Нужен собственный синтаксис (DSL)?
│ └── Да → Функциональный процедурный макрос
│
└── Нужно преобразовать функцию/структуру?
└── Да → Процедурный макрос-атрибут
Общее правило: если можно обойтись функцией или обобщением, не используйте макрос. У макросов хуже сообщения об ошибках, нет автодополнения IDE внутри тела макроса, и их труднее отлаживать.
Упражнения
🟢 Упражнение 1: макрос min!
Напишите макрос min!, который:
min!(a, b)возвращает меньшее из двух значенийmin!(a, b, c)возвращает наименьшее из трёх значений- Работает с любым типом, реализующим
PartialOrd
Подсказка: в вашем macro_rules! понадобятся два варианта ветвления.
Решение (нажмите, чтобы развернуть)
macro_rules! min {
($a:expr, $b:expr) => {
if $a < $b { $a } else { $b }
};
($a:expr, $b:expr, $c:expr) => {
min!(min!($a, $b), $c)
};
}
fn main() {
println!("{}", min!(3, 7)); // 3
println!("{}", min!(9, 2, 5)); // 2
println!("{}", min!(1.5, 0.3)); // 0.3
}
Примечание: для продакшн-кода лучше использовать std::cmp::min или a.min(b). Это упражнение демонстрирует устройство макросов с несколькими ветвями.
🟡 Упражнение 2: hashmap! с нуля
Не глядя на пример выше, напишите макрос hashmap!, который:
- Создаёт
HashMapиз парключ => значение - Поддерживает завершающие запятые
- Работает с любым хешируемым типом ключа
Проверьте на:
#![allow(unused)]
fn main() {
let m = hashmap! {
"name" => "Alice",
"role" => "Engineer",
};
assert_eq!(m["name"], "Alice");
assert_eq!(m.len(), 2);
}
Решение (нажмите, чтобы развернуть)
use std::collections::HashMap;
macro_rules! hashmap {
( $( $key:expr => $val:expr ),* $(,)? ) => {{
let mut map = HashMap::new();
$( map.insert($key, $val); )*
map
}};
}
fn main() {
let m = hashmap! {
"name" => "Alice",
"role" => "Engineer",
};
assert_eq!(m["name"], "Alice");
assert_eq!(m.len(), 2);
println!("Tests passed!");
}
🟡 Упражнение 3: assert_approx_eq! для сравнения чисел с плавающей точкой
Напишите макрос assert_approx_eq!(a, b, epsilon), который вызывает panic, если |a - b| > epsilon. Он полезен для тестирования вычислений с плавающей точкой, где точное равенство не выполняется.
Проверьте на:
#![allow(unused)]
fn main() {
assert_approx_eq!(0.1 + 0.2, 0.3, 1e-10); // Должно пройти
assert_approx_eq!(3.14159, std::f64::consts::PI, 1e-4); // Должно пройти
// assert_approx_eq!(1.0, 2.0, 0.5); // Должно вызвать panic
}
Решение (нажмите, чтобы развернуть)
macro_rules! assert_approx_eq {
($a:expr, $b:expr, $eps:expr) => {
let (a, b, eps) = ($a as f64, $b as f64, $eps as f64);
let diff = (a - b).abs();
if diff > eps {
panic!(
"assertion failed: |{} - {}| = {} > {} (epsilon)",
a, b, diff, eps
);
}
};
}
fn main() {
assert_approx_eq!(0.1 + 0.2, 0.3, 1e-10);
assert_approx_eq!(3.14159, std::f64::consts::PI, 1e-4);
println!("All float comparisons passed!");
}
🔴 Упражнение 4: impl_display_for_enum!
Напишите макрос, который генерирует реализацию Display для простых перечислений в стиле C. Дано:
#![allow(unused)]
fn main() {
impl_display_for_enum! {
enum Color {
Red => "red",
Green => "green",
Blue => "blue",
}
}
}
Он должен сгенерировать и определение enum Color { Red, Green, Blue }, И impl Display for Color, который сопоставляет каждый вариант с его строкой.
Подсказка: понадобятся и повторение $( ... ),*, и несколько спецификаторов фрагментов.
Решение (нажмите, чтобы развернуть)
use std::fmt;
macro_rules! impl_display_for_enum {
(enum $name:ident { $( $variant:ident => $display:expr ),* $(,)? }) => {
#[derive(Debug, Clone, Copy, PartialEq)]
enum $name {
$( $variant ),*
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
$( $name::$variant => write!(f, "{}", $display), )*
}
}
}
};
}
impl_display_for_enum! {
enum Color {
Red => "red",
Green => "green",
Blue => "blue",
}
}
fn main() {
let c = Color::Green;
println!("Color: {c}"); // "Color: green"
println!("Debug: {c:?}"); // "Debug: Green"
assert_eq!(format!("{}", Color::Red), "red");
println!("All tests passed!");
}