Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Паттерны Rust и практические руководства по инженерии

О проекте

  • Курс основан на оригинальных материалах Rust Training. Лицензии: MIT (код) и CC-BY-4.0 (текст).
  • Русский перевод и адаптация выполнены участниками проекта RustTraining-Rus.
  • Замечания и исправления присылайте через Issues и Pull Requests репозитория.

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

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

  • Разработчики, которые прошли The Rust Programming Language, но не понимают, как на практике спроектировать решение
  • Инженеры, переносящие промышленные системы с C++ и C# на Rust
  • Все, кто упёрся в стену с обобщёнными типами, границами трейтов или ошибками времён жизни и хочет систематический набор инструментов

Предварительные требования

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

  • владением, заимствованием и временами жизни (базовый уровень)
  • перечислениями, сопоставлением с образцом и Option/Result
  • структурами, методами и базовыми трейтами (Display, Debug, Clone)
  • основами Cargo: cargo build, cargo test, cargo run

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

Обозначения сложности

Каждая глава помечена уровнем сложности:

СимволУровеньЗначение
🟢ОсновыБазовые концепции, которые нужны каждому разработчику на Rust
🟡СреднийПаттерны, которые применяются в промышленных кодовых базах
🔴ПродвинутыйГлубинные механизмы языка: возвращайтесь к ним по мере необходимости

План изучения

ГлавыТемаРекомендуемое времяКонтрольная точка
Часть I: Паттерны на уровне типов
1. Обобщённые типы 🟢Мономорфизация, const-обобщения, const fn1–2 часаМожет объяснить, когда dyn Trait предпочтительнее обобщений
2. Трейты 🟡Ассоциированные типы, GAT, blanket-реализации, vtable3–4 часаМожет спроектировать трейт с ассоциированными типами
3. Newtype и type-state 🟡Безопасность типов без затрат во время выполнения, автоматы на этапе компиляции2–3 часаМожет реализовать builder-паттерн на основе type-state
4. PhantomData 🔴Маркировка времён жизни, вариантность, проверка drop2–3 часаМожет объяснить, почему PhantomData<fn(T)> отличается от PhantomData<T>
Часть II: Конкурентность и рантайм
5. Каналы 🟢mpsc, crossbeam, select!, акторы1–2 часаМожет реализовать пул воркеров на каналах
6. Конкурентность 🟡Потоки, rayon, Mutex, RwLock, атомарные типы2–3 часаМожет подобрать подходящий примитив синхронизации для задачи
7. Замыкания 🟢Fn/FnMut/FnOnce, комбинаторы1–2 часаМожет написать функцию высшего порядка, принимающую замыкания
8. Функциональный и императивный стили 🟡Цепочки итераторов, комбинаторы Option и Result, когда выбирать цикл2–3 часаМожет объяснить, когда комбинатор понятнее цикла, а когда нет
9. Умные указатели 🟡Box, Rc, Arc, RefCell, Cow, Pin2–3 часаМожет объяснить, когда применять каждый умный указатель
Часть III: Системы и продакшн
10. Обработка ошибок 🟢thiserror, anyhow, оператор ?1–2 часаМожет спроектировать иерархию типов ошибок
11. Сериализация 🟡serde, zero-copy, бинарные данные2–3 часаМожет написать собственный десериализатор serde
12. Unsafe 🔴Сверхспособности, FFI, подводные камни UB, аллокаторы2–3 часаМожет обернуть unsafe-код в корректный безопасный API
13. Макросы 🟡macro_rules!, процедурные макросы, syn/quote2–3 часаМожет написать декларативный макрос с tt munching
14. Тестирование 🟢Модульные, интеграционные и doc-тесты, proptest, criterion1–2 часаМожет настроить property-based тесты
15. Дизайн API 🟡Структура модулей, эргономичные API, флаги функций2–3 часаМожет применить паттерн «parse, don’t validate»
16. Async 🔴Futures, Tokio, распространённые ошибки1–2 часаМожет находить анти-паттерны async
Приложения
Справочная карточкаКраткая сводка по границам трейтов, временам жизни и паттернамПо необходимости—
Итоговый проектПланировщик задач с безопасностью типов4–6 часовСдайте работающую реализацию

Общее расчётное время: 30–45 часов для основательного изучения с упражнениями.

Работа с упражнениями

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

  1. Сначала попробуйте сами: потратьте минимум 15 минут, прежде чем открывать решение
  2. Набирайте код: не копируйте и не вставляйте, набор текста вырабатывает мышечную память
  3. Меняйте решение: добавьте функциональность, измените ограничение, намеренно что-нибудь сломайте
  4. Проверяйте перекрёстные ссылки: большинство упражнений сочетают паттерны из нескольких глав

Итоговый проект (в приложении) объединяет паттерны из всей книги в одну систему промышленного качества.

Оглавление

Часть I: Паттерны на уровне типов

1. Обобщённые типы — полная картина 🟢 Мономорфизация, компромиссы по размеру кода, обобщения против перечислений и трейт-объектов, const-обобщения, const fn.

2. Трейты в деталях 🟡 Ассоциированные типы, GAT, blanket-реализации, маркерные трейты, vtable, HRTB, трейты-расширения, диспетчеризация через перечисления.

3. Паттерны newtype и type-state 🟡 Безопасность типов без затрат во время выполнения, автоматы состояний на этапе компиляции, builder-паттерны, конфигурационные трейты.

4. PhantomData — типы, которые не несут данных 🔴 Маркировка времён жизни, паттерн «единицы измерения», проверка drop, вариантность.

Часть II: Конкурентность и рантайм

5. Каналы и передача сообщений 🟢 std::sync::mpsc, crossbeam, select!, обратное давление (backpressure), паттерн актора.

6. Конкурентность, параллелизм и потоки 🟡 Потоки ОС, scoped-потоки, rayon, Mutex/RwLock/атомарные типы, Condvar, OnceLock, паттерны без блокировок.

7. Замыкания и функции высшего порядка 🟢 Fn/FnMut/FnOnce, замыкания как параметры и возвращаемые значения, комбинаторы, API высшего порядка.

8. Функциональный и императивный стили: когда выигрывает изящество 🟡 Комбинаторы Option и Result, цепочки итераторов против циклов, оператор ?, построение коллекций, антипаттерны.

9. Умные указатели и внутренняя изменяемость 🟡 Box, Rc, Arc, Weak, Cell/RefCell, Cow, Pin, ManuallyDrop.

Часть III: Системы и продакшн

10. Паттерны обработки ошибок 🟢 thiserror против anyhow, #[from], .context(), оператор ?, паника.

11. Сериализация, zero-copy и бинарные данные 🟡 Основы serde, представления перечислений, десериализация без копирования, repr(C), bytes::Bytes.

12. Unsafe Rust — контролируемая опасность 🔴 Пять сверхспособностей, корректные абстракции, FFI, подводные камни UB, аллокаторы на основе арен и slab-аллокаторы.

13. Макросы — код, который пишет код 🟡 macro_rules!, когда (не) стоит использовать макросы, процедурные макросы, derive-макросы, syn/quote.

14. Паттерны тестирования и бенчмаркинга 🟢 Модульные, интеграционные и doc-тесты, proptest, criterion, стратегии моков.

15. Архитектура крейтов и дизайн API 🟡 Структура модулей, чек-лист дизайна API, эргономичные параметры, флаги функций, воркспейсы.

16. Основы async/await 🔴 Futures, быстрый старт с Tokio, распространённые ошибки. (Подробнее об async см. в нашем курсе по Async Rust.)

17. Упражнения Сводный набор упражнений по главам книги.

Приложения

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

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


1. Обобщённые типы — полная картина 🟢

Что вы узнаете:

  • Как мономорфизация даёт обобщения без накладных расходов и когда она приводит к раздуванию кода
  • Схему выбора: обобщения, перечисления или трейт-объекты
  • Const-обобщения для размеров массивов на этапе компиляции и const fn для вычислений на этапе компиляции
  • Когда на холодных путях стоит заменить статическую диспетчеризацию динамической

Мономорфизация и нулевые затраты

Обобщения в Rust мономорфизируются: компилятор генерирует специализированную копию каждой обобщённой функции для каждого конкретного типа, с которым она используется. Это противоположность Java и C#, где обобщения стираются во время выполнения.

fn max_of<T: PartialOrd>(a: T, b: T) -> T {
    if a >= b { a } else { b }
}

fn main() {
    max_of(3_i32, 5_i32);     // Компилятор сгенерирует max_of_i32
    max_of(2.0_f64, 7.0_f64); // Компилятор сгенерирует max_of_f64
    max_of("a", "z");         // Компилятор сгенерирует max_of_str
}

Что на самом деле создаёт компилятор (концептуально):

#![allow(unused)]
fn main() {
// Три отдельные функции — никакой динамической диспетчеризации и никакой vtable:
fn max_of_i32(a: i32, b: i32) -> i32 { if a >= b { a } else { b } }
fn max_of_f64(a: f64, b: f64) -> f64 { if a >= b { a } else { b } }
fn max_of_str<'a>(a: &'a str, b: &'a str) -> &'a str { if a >= b { a } else { b } }
}

Почему max_of_str требует <'a>, а max_of_i32 — нет? i32 и f64 — типы с Copy, функция возвращает копию значения, которая не зависит ни от каких заимствований. А &str — это ссылка, поэтому компилятор должен знать время жизни возвращаемой ссылки. Аннотация <'a> означает: «возвращаемый &str живёт не меньше, чем оба входных параметра».

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

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

#![allow(unused)]
fn main() {
// Rust: ошибка уже в месте определения — "T doesn't implement Display"
fn broken<T>(val: T) {
    println!("{val}"); // ❌ Error: T doesn't implement Display
}
}
#![allow(unused)]
fn main() {
// Исправление: добавляем ограничение
fn fixed<T: std::fmt::Display>(val: T) {
    println!("{val}"); // ✅
}
}

Когда обобщения вредят: раздувание кода

У мономорфизации есть цена — размер бинарного файла. Каждая уникальная инстанциация дублирует тело функции:

// Эта невинная функция...
fn serialize<T: serde::Serialize>(value: &T) -> Vec<u8> {
    serde_json::to_vec(value).unwrap()
}

// ...используемая с 50 разными типами → 50 копий в бинарном файле.

Стратегии смягчения:

// 1. Вынести необобщённое ядро (паттерн «outline»)
fn serialize<T: serde::Serialize>(value: &T) -> Result<Vec<u8>, serde_json::Error> {
    // Обобщённая часть: только вызов сериализации
    let json_value = serde_json::to_value(value)?;
    // Необобщённая часть: вынесена в отдельную функцию
    serialize_value(json_value)
}

fn serialize_value(value: serde_json::Value) -> Result<Vec<u8>, serde_json::Error> {
    // Эта функция существует в бинарном файле ТОЛЬКО ОДИН раз
    serde_json::to_vec(&value)
}

// 2. Использовать трейт-объекты (динамическая диспетчеризация), когда инлайнинг не критичен
fn log_item(item: &dyn std::fmt::Display) {
    // Одна копия — диспетчеризация через vtable
    println!("[LOG] {item}");
}

Практическое правило: используйте обобщения на горячих путях, где важен инлайнинг. Используйте dyn Trait на холодных путях (обработка ошибок, логирование, конфигурация), где вызов через vtable несущественен.

Обобщения, перечисления и трейт-объекты: руководство по выбору

В Rust есть три способа реализовать «разные типы с одинаковым интерфейсом»:

ПодходДиспетчеризацияИзвестен на этапеРасширяемый?Накладные расходы
Обобщения (impl Trait / <T: Trait>)Статическая (мономорфизация)Компиляции✅ (открытое множество)Нулевые — инлайнятся
ПеречислениеВетка matchКомпиляции❌ (закрытое множество)Нулевые — без vtable
Трейт-объект (dyn Trait)Динамическая (vtable)Выполнения✅ (открытое множество)Указатель на vtable и косвенный вызов
// --- ОБОБЩЕНИЯ: открытое множество, нулевые затраты, решается на этапе компиляции ---
fn process<H: Handler>(handler: H, request: Request) -> Response {
    handler.handle(request) // Мономорфизировано — по одной копии на каждый H
}

// --- ПЕРЕЧИСЛЕНИЕ: закрытое множество, нулевые затраты, исчерпывающее сопоставление ---
enum Shape {
    Circle(f64),
    Rect(f64, f64),
    Triangle(f64, f64, f64),
}

impl Shape {
    fn area(&self) -> f64 {
        match self {
            Shape::Circle(r) => std::f64::consts::PI * r * r,
            Shape::Rect(w, h) => w * h,
            Shape::Triangle(a, b, c) => {
                let s = (a + b + c) / 2.0;
                (s * (s - a) * (s - b) * (s - c)).sqrt()
            }
        }
    }
}
// При добавлении нового варианта придётся обновить ВСЕ ветки match: компилятор
// требует исчерпывающего сопоставления. Это удобно, когда вы контролируете все варианты.

// --- ТРЕЙТ-ОБЪЕКТ: открытое множество, затраты во время выполнения, расширяемый ---
fn log_all(items: &[Box<dyn std::fmt::Display>]) {
    for item in items {
        println!("{item}"); // диспетчеризация через vtable
    }
}

Схема выбора:

flowchart TD
    A["Знаете ли вы ВСЕ<br>возможные типы на<br>этапе компиляции?"]
    A -->|"Да, небольшое<br>закрытое множество"| B["Перечисление"]
    A -->|"Да, но множество<br>открытое"| C["Обобщения<br>(мономорфизированные)"]
    A -->|"Нет — типы определяются<br>во время выполнения"| D["dyn Trait"]

    C --> E{"Горячий путь?<br>(миллионы вызовов)"}
    E -->|Да| F["Обобщения<br>(инлайнируемые)"]
    E -->|Нет| G["dyn Trait<br>подойдёт"]

    D --> H{"Нужны разные типы<br>в одной коллекции?"}
    H -->|Да| I["Vec&lt;Box&lt;dyn Trait&gt;&gt;"]
    H -->|Нет| C

    style A fill:#e8f4f8,stroke:#2980b9,color:#000
    style B fill:#d4efdf,stroke:#27ae60,color:#000
    style C fill:#d4efdf,stroke:#27ae60,color:#000
    style D fill:#fdebd0,stroke:#e67e22,color:#000
    style F fill:#d4efdf,stroke:#27ae60,color:#000
    style G fill:#fdebd0,stroke:#e67e22,color:#000
    style I fill:#fdebd0,stroke:#e67e22,color:#000
    style E fill:#fef9e7,stroke:#f1c40f,color:#000
    style H fill:#fef9e7,stroke:#f1c40f,color:#000

Const-обобщения

Начиная с Rust 1.51, типы и функции можно параметризовать константными значениями, а не только типами:

#![allow(unused)]
fn main() {
// Обёртка над массивом, параметризованная размерами
struct Matrix<const ROWS: usize, const COLS: usize> {
    data: [[f64; COLS]; ROWS],
}

impl<const ROWS: usize, const COLS: usize> Matrix<ROWS, COLS> {
    fn new() -> Self {
        Matrix { data: [[0.0; COLS]; ROWS] }
    }

    fn transpose(&self) -> Matrix<COLS, ROWS> {
        let mut result = Matrix::<COLS, ROWS>::new();
        for r in 0..ROWS {
            for c in 0..COLS {
                result.data[c][r] = self.data[r][c];
            }
        }
        result
    }
}

// Компилятор следит за согласованностью размерностей:
fn multiply<const M: usize, const N: usize, const P: usize>(
    a: &Matrix<M, N>,
    b: &Matrix<N, P>, // N должно совпадать!
) -> Matrix<M, P> {
    let mut result = Matrix::<M, P>::new();
    for i in 0..M {
        for j in 0..P {
            for k in 0..N {
                result.data[i][j] += a.data[i][k] * b.data[k][j];
            }
        }
    }
    result
}

// Использование:
let a = Matrix::<2, 3>::new(); // 2×3
let b = Matrix::<3, 4>::new(); // 3×4
let c = multiply(&a, &b);      // 2×4 ✅

// let d = Matrix::<5, 5>::new();
// multiply(&a, &d); // ❌ Compile error: expected Matrix<3, _>, got Matrix<5, 5>
}

Сравнение с C++: это похоже на template<int N> в C++, но const-обобщения в Rust проверяются сразу и не страдают от сложности SFINAE.

Константные функции (const fn)

const fn помечает функцию как вычислимую на этапе компиляции. Это аналог constexpr в C++. Результат можно использовать в контекстах const и static:

#![allow(unused)]
fn main() {
// Простая const fn — вычисляется на этапе компиляции в константном контексте
const fn celsius_to_fahrenheit(c: f64) -> f64 {
    c * 9.0 / 5.0 + 32.0
}

const BOILING_F: f64 = celsius_to_fahrenheit(100.0); // Вычисляется на этапе компиляции
const FREEZING_F: f64 = celsius_to_fahrenheit(0.0);  // 32.0

// Константные конструкторы — создаём статические значения без lazy_static!
struct BitMask(u32);

impl BitMask {
    const fn new(bit: u32) -> Self {
        BitMask(1 << bit)
    }

    const fn or(self, other: BitMask) -> Self {
        BitMask(self.0 | other.0)
    }

    const fn contains(&self, bit: u32) -> bool {
        self.0 & (1 << bit) != 0
    }
}

// Статическая таблица поиска — без затрат во время выполнения и без ленивой инициализации
const GPIO_INPUT:  BitMask = BitMask::new(0);
const GPIO_OUTPUT: BitMask = BitMask::new(1);
const GPIO_IRQ:    BitMask = BitMask::new(2);
const GPIO_IO:     BitMask = GPIO_INPUT.or(GPIO_OUTPUT);

// Таблицы регистров как константные массивы:
const SENSOR_THRESHOLDS: [u16; 4] = {
    let mut table = [0u16; 4];
    table[0] = 50;   // Предупреждение
    table[1] = 70;   // Высокая температура
    table[2] = 85;   // Критическая
    table[3] = 100;  // Аварийное отключение
    table
};
// Вся таблица находится в бинарном файле: без кучи и без инициализации во время выполнения.
}

Что МОЖНО делать в const fn (начиная с Rust 1.79):

  • арифметика, битовые операции, сравнения
  • if/else, match, loop, while (управление потоком)
  • создание и изменение локальных переменных (let mut)
  • вызов других const fn
  • ссылки (&, &mut в пределах константного контекста)
  • panic!() (становится ошибкой компиляции, если достигается на этапе компиляции)
  • базовая арифметика с плавающей точкой (+, -, *, /; сложные операции вроде sqrt/sin не могут быть const)

Чего НЕЛЬЗЯ (пока):

  • выделение памяти в куче (Box, Vec, String)
  • вызов методов трейтов (только собственные методы типа)
  • ввод-вывод и побочные эффекты
#![allow(unused)]
fn main() {
// const fn с panic — становится ошибкой компиляции:
const fn checked_div(a: u32, b: u32) -> u32 {
    if b == 0 {
        panic!("деление на ноль"); // Ошибка компиляции, если b равно 0 на этапе const
    }
    a / b
}

const RESULT: u32 = checked_div(100, 4);  // ✅ 25
// const BAD: u32 = checked_div(100, 0);  // ❌ Ошибка компиляции: паника «деление на ноль»
}

Сравнение с C++: const fn — это аналог constexpr в Rust. Ключевое отличие: в Rust это явный выбор, и компилятор строго проверяет, что используются только операции, совместимые с const. Функции constexpr в C++ могут молча перейти к вычислению во время выполнения. В Rust контекст const требует вычисления на этапе компиляции, иначе это жёсткая ошибка.

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

Ключевые выводы — обобщения

  • Мономорфизация даёт абстракции без накладных расходов, но может раздувать код — на холодных путях используйте dyn Trait
  • Const-обобщения ([T; N]) заменяют шаблонные трюки C++ размерами массивов, проверяемыми на этапе компиляции
  • const fn избавляет от lazy_static! для значений, которые можно вычислить на этапе компиляции

См. также: гл. 2 — Трейты в деталях — ограничения трейтов, ассоциированные типы и трейт-объекты. гл. 4 — PhantomData — маркеры-обобщения нулевого размера.


Упражнение: обобщённый кэш с вытеснением ★★ (~30 минут)

Реализуйте обобщённую структуру Cache<K, V>, которая хранит пары «ключ — значение» с настраиваемой максимальной ёмкостью. Когда кэш заполнен, вытесняется самая старая запись (FIFO). Требования:

  • fn new(capacity: usize) -> Self
  • fn insert(&mut self, key: K, value: V) — вытесняет самую старую запись, если достигнута ёмкость
  • fn get(&self, key: &K) -> Option<&V>
  • fn len(&self) -> usize
  • Ограничьте K: Eq + Hash + Clone
🔑 Решение
use std::collections::{HashMap, VecDeque};
use std::hash::Hash;

struct Cache<K, V> {
    map: HashMap<K, V>,
    order: VecDeque<K>,
    capacity: usize,
}

impl<K: Eq + Hash + Clone, V> Cache<K, V> {
    fn new(capacity: usize) -> Self {
        Cache {
            map: HashMap::with_capacity(capacity),
            order: VecDeque::with_capacity(capacity),
            capacity,
        }
    }

    fn insert(&mut self, key: K, value: V) {
        if self.capacity == 0 {
            // нет ёмкости!
            return;
        }
        if self.map.contains_key(&key) {
            self.map.insert(key, value);
            return;
        }
        if self.map.len() >= self.capacity {
            if let Some(oldest) = self.order.pop_front() {
                self.map.remove(&oldest);
            }
        }
        self.order.push_back(key.clone());
        self.map.insert(key, value);
    }

    fn get(&self, key: &K) -> Option<&V> {
        self.map.get(key)
    }

    fn len(&self) -> usize {
        self.map.len()
    }
}

fn main() {
    // Тест базового кэша
    let mut cache = Cache::new(3);
    cache.insert("a", 1);
    cache.insert("b", 2);
    cache.insert("c", 3);
    assert_eq!(cache.len(), 3);

    cache.insert("d", 4); // Вытесняет "a"
    assert_eq!(cache.get(&"a"), None);
    assert_eq!(cache.get(&"d"), Some(&4));

    // Упражнение для читателя: каким должен быть тип атрибута `capacity`,
    // чтобы нельзя было создать такой бесполезный кэш?
    let mut empty_cache = Cache::new(0);
    empty_cache.insert("0", 0);
    assert_eq!(empty_cache.get(&"0"), None);
    assert_eq!(empty_cache.len(), 0);

    println!("Кэш работает! len = {}", cache.len());
}

2. Трейты в деталях 🟡

Что вы узнаете:

  • Ассоциированные типы и обобщённые параметры: когда что использовать
  • GAT, blanket-реализации, маркерные трейты и правила dyn-совместимости
  • Как устроены vtable и «толстые» указатели
  • Трейты-расширения, диспетчеризация через перечисления и паттерны типизированных команд

Ассоциированные типы и обобщённые параметры

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

#![allow(unused)]
fn main() {
// --- АССОЦИИРОВАННЫЙ ТИП: одна реализация на тип ---
trait Iterator {
    type Item; // Каждый итератор выдаёт ровно ОДИН вид элементов

    fn next(&mut self) -> Option<Self::Item>;
}

// Пользовательский итератор, который всегда возвращает i32 — выбора здесь нет
struct Counter { max: i32, current: i32 }

impl Iterator for Counter {
    type Item = i32; // Ровно один тип Item в каждой реализации
    fn next(&mut self) -> Option<i32> {
        if self.current < self.max {
            self.current += 1;
            Some(self.current)
        } else {
            None
        }
    }
}

// --- ОБОБЩЁННЫЙ ПАРАМЕТР: несколько реализаций на тип ---
trait Convert<T> {
    fn convert(&self) -> T;
}

// Один тип может реализовать Convert для МНОГИХ целевых типов:
impl Convert<f64> for i32 {
    fn convert(&self) -> f64 { *self as f64 }
}
impl Convert<String> for i32 {
    fn convert(&self) -> String { self.to_string() }
}
}

Когда что использовать:

СлучайКогда
Ассоциированный типДля каждого реализующего типа есть ровно ОДИН естественный результат. Iterator::Item, Deref::Target, Add::Output
Обобщённый параметрТип может осмысленно реализовывать трейт для МНОГИХ разных типов. From<T>, AsRef<T>, PartialEq<Rhs>

Интуиция: если осмысленно спросить «каков Item у этого итератора?», используйте ассоциированный тип. Если осмысленно спросить «можно ли это преобразовать в f64? в String? в bool?», используйте обобщённый параметр.

#![allow(unused)]
fn main() {
// Пример из реального мира: std::ops::Add
trait Add<Rhs = Self> {
    type Output; // Ассоциированный тип — у сложения ОДИН тип результата
    fn add(self, rhs: Rhs) -> Self::Output;
}

// Rhs — обобщённый параметр: к Meters можно прибавлять разные типы:
struct Meters(f64);
struct Centimeters(f64);

impl Add<Meters> for Meters {
    type Output = Meters;
    fn add(self, rhs: Meters) -> Meters { Meters(self.0 + rhs.0) }
}
impl Add<Centimeters> for Meters {
    type Output = Meters;
    fn add(self, rhs: Centimeters) -> Meters { Meters(self.0 + rhs.0 / 100.0) }
}
}

Обобщённые ассоциированные типы (GAT)

Начиная с Rust 1.65, ассоциированные типы могут иметь собственные обобщённые параметры. Это позволяет реализовать lending-итераторы: итераторы, которые возвращают ссылки, привязанные к самому итератору, а не к базовой коллекции.

#![allow(unused)]
fn main() {
// Без GAT выразить lending-итератор невозможно:
// trait LendingIterator {
//     type Item<'a>;  // ← До 1.65 это отклонялось компилятором
// }

// С GAT (Rust 1.65+):
trait LendingIterator {
    type Item<'a> where Self: 'a;

    fn next(&mut self) -> Option<Self::Item<'_>>;
}

// Пример: итератор, который выдаёт перекрывающиеся окна
struct WindowIter<'data> {
    data: &'data [u8],
    pos: usize,
    window_size: usize,
}

impl<'data> LendingIterator for WindowIter<'data> {
    type Item<'a> = &'a [u8] where Self: 'a;

    fn next(&mut self) -> Option<&[u8]> {
        if self.pos + self.window_size <= self.data.len() {
            let window = &self.data[self.pos..self.pos + self.window_size];
            self.pos += 1;
            Some(window)
        } else {
            None
        }
    }
}
}

Когда нужны GAT: lending-итераторы, потоковые парсеры и любые трейты, где время жизни ассоциированного типа зависит от заимствования &self. Для большинства кода достаточно обычных ассоциированных типов.

Супертрейты и иерархии трейтов

Трейты могут требовать другие трейты как предварительное условие, формируя иерархии:

graph BT
    Display["Display"]
    Debug["Debug"]
    Error["Error"]
    Clone["Clone"]
    Copy["Copy"]
    PartialEq["PartialEq"]
    Eq["Eq"]
    PartialOrd["PartialOrd"]
    Ord["Ord"]

    Error --> Display
    Error --> Debug
    Copy --> Clone
    Eq --> PartialEq
    Ord --> Eq
    Ord --> PartialOrd
    PartialOrd --> PartialEq

    style Display fill:#e8f4f8,stroke:#2980b9,color:#000
    style Debug fill:#e8f4f8,stroke:#2980b9,color:#000
    style Error fill:#fdebd0,stroke:#e67e22,color:#000
    style Clone fill:#d4efdf,stroke:#27ae60,color:#000
    style Copy fill:#d4efdf,stroke:#27ae60,color:#000
    style PartialEq fill:#fef9e7,stroke:#f1c40f,color:#000
    style Eq fill:#fef9e7,stroke:#f1c40f,color:#000
    style PartialOrd fill:#fef9e7,stroke:#f1c40f,color:#000
    style Ord fill:#fef9e7,stroke:#f1c40f,color:#000

Стрелки направлены от субтрейта к супертрейту: реализация Error требует Display и Debug.

Трейт может потребовать, чтобы его реализаторы реализовывали и другие трейты:

#![allow(unused)]
fn main() {
use std::fmt;

// Display — супертрейт для Error
trait Error: fmt::Display + fmt::Debug {
    fn source(&self) -> Option<&(dyn Error + 'static)> { None }
}
// Любой тип, реализующий Error, ОБЯЗАН также реализовать Display и Debug

// Строим собственные иерархии:
trait Identifiable {
    fn id(&self) -> u64;
}

trait Timestamped {
    fn created_at(&self) -> chrono::DateTime<chrono::Utc>;
}

// Entity требует оба:
trait Entity: Identifiable + Timestamped {
    fn is_active(&self) -> bool;
}

// Реализация Entity заставляет реализовать все три трейта:
struct User { id: u64, name: String, created: chrono::DateTime<chrono::Utc> }

impl Identifiable for User {
    fn id(&self) -> u64 { self.id }
}
impl Timestamped for User {
    fn created_at(&self) -> chrono::DateTime<chrono::Utc> { self.created }
}
impl Entity for User {
    fn is_active(&self) -> bool { true }
}
}

Blanket-реализации

Реализуйте трейт сразу для ВСЕХ типов, которые удовлетворяют некоторому ограничению:

#![allow(unused)]
fn main() {
// Так устроена std: любой тип с Display автоматически получает ToString
impl<T: fmt::Display> ToString for T {
    fn to_string(&self) -> String {
        format!("{self}")
    }
}
// Теперь i32, &str и ваши собственные типы, у которых есть Display, получают to_string() бесплатно.

// Собственная blanket-реализация:
trait Loggable {
    fn log(&self);
}

// Каждый тип с Debug автоматически становится Loggable:
impl<T: std::fmt::Debug> Loggable for T {
    fn log(&self) {
        eprintln!("[LOG] {self:?}");
    }
}

// Теперь ЛЮБОЙ тип с Debug имеет метод .log():
// 42.log();              // [LOG] 42
// "hello".log();         // [LOG] "hello"
// vec![1, 2, 3].log();   // [LOG] [1, 2, 3]
}

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

Маркерные трейты

Трейты без методов помечают тип как обладающий некоторым свойством:

#![allow(unused)]
fn main() {
// Маркерные трейты стандартной библиотеки:
// Send    — безопасно передавать между потоками
// Sync    — безопасно разделять (&T) между потоками
// Unpin   — безопасно перемещать после закрепления (pin)
// Sized   — размер известен на этапе компиляции
// Copy    — может дублироваться через memcpy

// Собственный маркерный трейт:
/// Маркер: этот датчик прошёл заводскую калибровку
trait Calibrated {}

struct RawSensor { reading: f64 }
struct CalibratedSensor { reading: f64 }

impl Calibrated for CalibratedSensor {}

// В продакшене используются только откалиброванные датчики:
fn record_measurement<S: Calibrated>(sensor: &S) {
    // ...
}
// record_measurement(&RawSensor { reading: 0.0 }); // ❌ Ошибка компиляции
// record_measurement(&CalibratedSensor { reading: 0.0 }); // ✅
}

Это напрямую связано с паттерном type-state из главы 3.

Dyn-совместимость (ранее «объектная безопасность»)

Замечание о названии: это называлось object safety до Rust 1.84. Термин вводил в заблуждение с обеих сторон: в Rust нет «объектов» в смысле ООП, и речь здесь не о безопасности памяти. Вопрос простой: «можно ли использовать этот трейт как dyn Trait?». Компилятор теперь выдаёт сообщение the trait 'X' is not dyn compatible (по-прежнему ошибка E0038), но большинство существующих статей, документации крейтов и ответов на Stack Overflow по-прежнему пишут «object safe» — это то же самое понятие.

Не каждый трейт можно использовать как dyn Trait. Трейт является dyn-совместимым только если:

  1. Sized не должен быть супертрейтом — то есть трейт не должен требовать Self: Sized
  2. Нет обобщённых параметров типа у методов — параметры-времена жизни допустимы, так как стираются до кодогенерации и не требуют дополнительного слота в vtable
  3. Self не используется нигде в сигнатуре метода, кроме типа приёмника — это касается и параметров, и возвращаемых типов
  4. Каждая ассоциированная функция должна быть диспетчеризуемой или исключённой. Диспетчеризуемый метод требует приёмник типа &self, &mut self, self: Box<Self>, self: Rc<Self>, self: Arc<Self> или self: Pin<P>, где P — один из перечисленных. Всё остальное, включая голую fn create() -> Self, должно иметь where Self: Sized, чтобы исключиться из vtable
  5. Все супертрейты тоже должны быть dyn-совместимы — это свойство наследуется
  6. Нет ассоциированных констант и нет ассоциированных типов с обобщениями (GAT). Обычные ассоциированные типы допустимы, но должны быть зафиксированы в месте использования: dyn Iterator<Item = u32>, а не голый dyn Iterator
  7. Нет непрозрачных возвращаемых типов у диспетчеризуемых методов — ни async fn (которая скрывает тип Future), ни возвращаемый impl Trait
#![allow(unused)]
fn main() {
// ✅ Dyn-совместим — можно использовать как dyn Drawable
trait Drawable {
    fn draw(&self);
    fn bounding_box(&self) -> (f64, f64, f64, f64);
}

let shapes: Vec<Box<dyn Drawable>> = vec![/* ... */]; // ✅ Работает

// ❌ НЕ dyn-совместим — упоминает Self вне приёмника
trait Cloneable {
    fn clone_self(&self) -> Self;
    //                      ^^^^ "...because method `clone_self` references
    //                            the `Self` type in its return type"
}
// let items: Vec<Box<dyn Cloneable>> = ...; // ❌ Ошибка компиляции

// ❌ То же правило для аргумента — поэтому PartialEq не dyn-совместим
trait Comparable {
    fn equals(&self, other: &Self) -> bool;
    //                       ^^^^ "...references the `Self` type in this parameter"
}

// ⚠️ Оборачивание в Box НЕ помогает — Box<Self> всё равно называет Self
trait Spawner {
    fn spawn(&self) -> Box<Self>; // ❌ Всё равно не dyn-совместим
}

// ✅ Вместо этого верните Box<dyn Trait> — это конкретный тип, а не Self.
// Это стандартная идиома «клонирования через трейт-объект»:
trait CloneableDyn {
    fn clone_box(&self) -> Box<dyn CloneableDyn>;
}

// ❌ НЕ dyn-совместим — обобщённый метод
trait Converter {
    fn convert<T>(&self) -> T;
    //        ^^^ vtable не может содержать бесконечное число мономорфизаций
}

// ✅ Dyn-совместим — обобщённое ВРЕМЯ ЖИЗНИ допустимо, запрещены только параметры типа
trait Tokenizer {
    fn first_token<'a>(&self, input: &'a str) -> &'a str;
    //            ^^^^ Одного слота vtable достаточно: времена жизни стираются
}

// ❌ НЕ dyn-совместим — ассоциированная функция без приёмника
trait Factory {
    fn create() -> Self;
    // "...because associated function `create` has no `self` parameter"
}

// ✅ Та же функция, исключённая из vtable — трейт снова dyn-совместим
trait FactoryFixed {
    fn describe(&self) -> String;      // диспетчеризуется
    fn create() -> Self where Self: Sized; // исключена из vtable
}

// ❌ НЕ dyn-совместим — унаследовано от супертрейта, который таковым не является.
//    С самим Derived всё в порядке; компилятор указывает на Base::make.
trait Base {
    fn make() -> Self;
}
trait Derived: Base {
    fn show(&self);
}
// let d: &dyn Derived = ...; // ❌ Ошибка компиляции, вина на `Base::make`

// ❌ НЕ dyn-совместим — ассоциированная константа не имеет представления в vtable
trait Sensor {
    const MAX: f64;
    //    ^^^ "...because it contains associated const `MAX`"
    fn read(&self) -> f64;
}
// Обходной путь: сделайте её методом — `fn max_reading(&self) -> f64`. Здесь НЕТ
// выхода через `where Self: Sized`; `const MAX: f64 where Self: Sized;`
// даже не является допустимым синтаксисом в stable (обобщённые const-элементы нестабильны).

// ❌ НЕ dyn-совместим — GAT описывает семейство типов, а не один тип
trait LendingIterator {
    type Item<'a> where Self: 'a;
    //   ^^^^ "...because it contains generic associated type `Item`"
    fn next(&mut self) -> Option<Self::Item<'_>>;
}
// Это тот же LendingIterator из раздела о GAT выше: цена lending-итератора в том,
// что `dyn LendingIterator` существовать не может.

// ❌ НЕ dyn-совместим — непрозрачный возвращаемый тип (RPITIT)
trait Container {
    fn items(&self) -> impl Iterator<Item = u32>;
    //                 ^^^^ "...references an `impl Trait` type in its return type"
}

// ❌ НЕ dyn-совместим — `async fn` — та же проблема, только с синтаксическим сахаром
trait DataStore {
    async fn get(&self, key: &str) -> Option<String>;
    // "...because method `get` is `async`"
}
// Обходной путь для обоих случаев: сами стирайте тип и возвращайте конкретный
// boxed-трейт-объект — `Box<dyn Iterator<Item = u32> + '_>` и
// `Pin<Box<dyn Future<Output = Option<String>> + '_>>` — обычные типы,
// поэтому у них есть обычные слоты vtable.

// ⚠️ `self` по значению НЕ является диспетчеризуемым приёмником — подразумевается
//    `where Self: Sized`. Трейт остаётся dyn-совместимым, но метод просто
//    не входит в vtable и не может вызываться через `dyn Trait`:
trait Consume {
    fn describe(&self) -> String; // входит в vtable
    fn consume(self) -> String;   // неявно `where Self: Sized`
}
// let t: &dyn Consume = &token;  // ✅ сам трейт-объект допустим
// boxed.consume();               // ❌ error[E0161]: cannot move a value
//                                //    of type `dyn Consume`
}

Обходные пути:

#![allow(unused)]
fn main() {
// Добавьте `where Self: Sized`, чтобы исключить метод из vtable:
trait MyTrait {
    fn regular_method(&self); // Входит в vtable

    fn generic_method<T>(&self) -> T
    where
        Self: Sized; // Исключён из vtable — нельзя вызвать через dyn MyTrait
}

// Теперь dyn MyTrait допустим, но generic_method можно вызвать
// только тогда, когда конкретный тип известен.
}

Практическое правило: если вы собираетесь использовать dyn Trait, держите методы простыми: без обобщённых параметров типа, без Self вне приёмника и без Sized в виде супертрейта. Обратите внимание на асимметрию: trait Widget: Sized фатален, тогда как where Self: Sized на отдельном методе — санкционированный способ исключения, показанный выше. Если сомневаетесь, попробуйте let _: Box<dyn YourTrait>; и пусть компилятор подскажет.

Почему AsyncFn не dyn-совместим: Reference перечисляет AsyncFn, AsyncFnMut и AsyncFnOnce как отдельное правило, но компилятор показывает, что это следствие правила про GAT выше: он указывает на AsyncFnMut::CallRefFuture<'a> — обобщённый ассоциированный тип из определения в std. У обычных Fn/FnMut/FnOnce такого члена нет, поэтому они dyn-совместимы, и именно поэтому Box<dyn Fn(u32) -> u32> работает нормально.

См. также: раздел про RPITIT ниже использует -> impl Trait в определении трейта — это удобно, но стоит трейту dyn-совместимости. Async Book — гл. 10 подробно разбирает часть про async fn и обходной путь с Pin<Box<dyn Future>>.

Трейт-объекты изнутри: vtable и «толстые» указатели

&dyn Trait (или Box<dyn Trait>) — это «толстый» указатель, состоящий из двух машинных слов:

┌──────────────────────────────────────────────────┐
│  &dyn Drawable (на 64-битной системе: 16 байт)   │
├──────────────┬───────────────────────────────────┤
│  data_ptr    │  vtable_ptr                       │
│  (8 байт)    │  (8 байт)                         │
│  ↓           │  ↓                                │
│  ┌─────────┐ │  ┌──────────────────────────────┐ │
│  │ Circle  │ │  │ vtable для <Circle as        │ │
│  │ {       │ │  │           Drawable>          │ │
│  │  r: 5.0 │ │  │                              │ │
│  │ }       │ │  │  drop_in_place: 0x7f...a0    │ │
│  └─────────┘ │  │  size:           8           │ │
│              │  │  align:          8           │ │
│              │  │  draw:          0x7f...b4    │ │
│              │  │  bounding_box:  0x7f...c8    │ │
│              │  └──────────────────────────────┘ │
└──────────────┴───────────────────────────────────┘

Как работает вызов через vtable (например, shape.draw()):

  1. Загрузить vtable_ptr из «толстого» указателя (второе слово)
  2. Найти в vtable указатель на функцию draw
  3. Вызвать её, передав data_ptr в качестве аргумента self

По стоимости это похоже на виртуальную диспетчеризацию C++ (одно косвенное обращение на вызов), но Rust хранит указатель на vtable внутри «толстого» указателя, а не внутри самого объекта. Поэтому обычный Circle на стеке вообще не содержит указателя на vtable.

trait Drawable {
    fn draw(&self);
    fn area(&self) -> f64;
}

struct Circle { radius: f64 }

impl Drawable for Circle {
    fn draw(&self) { println!("Рисуем круг r={}", self.radius); }
    fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }
}

struct Square { side: f64 }

impl Drawable for Square {
    fn draw(&self) { println!("Рисуем квадрат s={}", self.side); }
    fn area(&self) -> f64 { self.side * self.side }
}

fn main() {
    let shapes: Vec<Box<dyn Drawable>> = vec![
        Box::new(Circle { radius: 5.0 }),
        Box::new(Square { side: 3.0 }),
    ];

    // Каждый элемент — «толстый» указатель: (data_ptr, vtable_ptr)
    // vtable для Circle и Square РАЗНЫЕ
    for shape in &shapes {
        shape.draw();  // диспетчеризация через vtable → Circle::draw или Square::draw
        println!("  площадь = {:.2}", shape.area());
    }

    // Сравнение размеров:
    println!("Размер size_of::<&Circle>()        = {}", size_of::<&Circle>());
    // → 8 байт (один указатель — компилятор знает тип)
    println!("Размер size_of::<&dyn Drawable>()  = {}", size_of::<&dyn Drawable>());
    // → 16 байт (data_ptr + vtable_ptr)
}

Модель стоимости производительности:

АспектСтатическая диспетчеризация (impl Trait / обобщения)Динамическая диспетчеризация (dyn Trait)
Накладные расходы на вызовНулевые: инлайнится LLVMОдно косвенное обращение через указатель на каждый вызов
Инлайнинг✅ Компилятор может инлайнить❌ Непрозрачный указатель на функцию
Размер бинарного файлаБольше (по копии на каждый тип)Меньше (одна общая функция)
Размер указателя«Тонкий» (1 слово)«Толстый» (2 слова)
Разнородные коллекции❌✅ Vec<Box<dyn Trait>>

Когда стоимость vtable имеет значение: в узких циклах, которые миллионы раз вызывают метод трейта, косвенность и невозможность инлайнинга могут заметно замедлить код (в 2–10 раз). Для холодных путей, конфигурации и архитектур с плагинами гибкость dyn Trait стоит небольшой цены.

Трейт-границы высшего ранга (HRTB)

Иногда нужна функция, которая работает со ссылками с любым временем жизни, а не с каким-то конкретным. Здесь и появляется синтаксис for<'a>:

// Проблема: этой функции нужно замыкание, которое может обрабатывать
// ссылки с ЛЮБЫМ временем жизни, а не с каким-то одним конкретным.

// ❌ Это слишком ограничительно — 'a фиксируется вызывающей стороной:
// fn apply<'a, F: Fn(&'a str) -> &'a str>(f: F, data: &'a str) -> &'a str

// ✅ HRTB: F должен работать для ВСЕХ возможных времён жизни:
fn apply<F>(f: F, data: &str) -> &str
where
    F: for<'a> Fn(&'a str) -> &'a str,
{
    f(data)
}

fn main() {
    let result = apply(|s| s.trim(), "  hello  ");
    println!("{result}"); // "hello"
}

Где вы встретите HRTB:

  • Трейты Fn(&T) -> &U: в большинстве случаев компилятор выводит for<'a> автоматически
  • Собственные реализации трейтов, которые должны работать с разными заимствованиями
  • Десериализация с serde: for<'de> Deserialize<'de>
// DeserializeOwned в serde определён так:
// trait DeserializeOwned: for<'de> Deserialize<'de> {}
// Смысл: «может быть десериализован из данных с ЛЮБЫМ временем жизни»
// (то есть результат не заимствует данные из входа)

use serde::de::DeserializeOwned;

fn parse_json<T: DeserializeOwned>(input: &str) -> T {
    serde_json::from_str(input).unwrap()
}

Практический совет: сами вы редко будете писать for<'a>. Чаще всего он появляется в ограничениях трейтов для параметров замыканий, и компилятор обрабатывает его неявно. Но если узнавать его в сообщениях об ошибках («expected a for<'a> Fn(&'a ...) bound»), проще понять, чего от вас хочет компилятор.

impl Trait: позиция аргумента и позиция возвращаемого значения

impl Trait встречается в двух позициях с разной семантикой:

#![allow(unused)]
fn main() {
// --- impl Trait в позиции аргумента (APIT) ---
// «Вызывающая сторона выбирает тип» — синтаксический сахар для обобщённого параметра
fn print_all(items: impl Iterator<Item = i32>) {
    for item in items { println!("{item}"); }
}
// Эквивалентно:
fn print_all_verbose<I: Iterator<Item = i32>>(items: I) {
    for item in items { println!("{item}"); }
}
// Решает вызывающая сторона: print_all(vec![1,2,3].into_iter())
//                            print_all(0..10)

// --- impl Trait в позиции возвращаемого значения (RPIT) ---
// «Вызываемая функция выбирает тип» — функция сама определяет конкретный тип
fn evens(limit: i32) -> impl Iterator<Item = i32> {
    (0..limit).filter(|x| x % 2 == 0)
    // Конкретный тип — Filter<Range<i32>, Closure>,
    // но вызывающая сторона видит лишь «какой-то Iterator<Item = i32>»
}
}

Ключевое отличие:

APIT (fn foo(x: impl T))RPIT (fn foo() -> impl T)
Кто выбирает тип?Вызывающая сторонаВызываемая функция (тело функции)
Мономорфизируется?Да: по копии на каждый типДа: один конкретный тип
Turbofish?Нет (foo::<X>() недопустим)Неприменимо
Эквивалентноfn foo<X: T>(x: X)Экзистенциальный тип

RPIT в определениях трейтов (RPITIT)

Начиная с Rust 1.75, можно использовать -> impl Trait прямо в определениях трейтов:

#![allow(unused)]
fn main() {
trait Container {
    fn items(&self) -> impl Iterator<Item = &str>;
    //                 ^^^^ Каждая реализация возвращает свой конкретный тип
}

struct CsvRow {
    fields: Vec<String>,
}

impl Container for CsvRow {
    fn items(&self) -> impl Iterator<Item = &str> {
        self.fields.iter().map(String::as_str)
    }
}

struct FixedFields;

impl Container for FixedFields {
    fn items(&self) -> impl Iterator<Item = &str> {
        ["host", "port", "timeout"].into_iter()
    }
}
}

До Rust 1.75 для этого в трейтах приходилось использовать Box<dyn Iterator> или ассоциированный тип. RPITIT убирает необходимость в выделении памяти в куче.

Но это стоит dyn-совместимости: -> impl Trait — непрозрачный возвращаемый тип, поэтому dyn Container отклоняется (см. Dyn-совместимость, правило 7). Если вам нужен трейт-объект, продолжайте возвращать Box<dyn Iterator<Item = &str> + '_> и платите за выделение памяти.

impl Trait или dyn Trait: руководство по выбору

Известен ли конкретный тип на этапе компиляции?
├── ДА  → Используйте impl Trait или обобщения (нулевые затраты, можно инлайнить)
└── НЕТ → Нужна ли разнородная коллекция?
     ├── ДА → Используйте dyn Trait (Box<dyn T>, &dyn T)
     └── НЕТ → Нужен ли ОДИН И ТОТ ЖЕ трейт-объект на границе API?
          ├── ДА → Используйте dyn Trait
          └── НЕТ → Используйте обобщения / impl Trait
Характеристикаimpl Traitdyn Trait
ДиспетчеризацияСтатическая (мономорфизация)Динамическая (vtable)
ПроизводительностьЛучшая: можно инлайнитьОдно косвенное обращение на вызов
Разнородные коллекции❌✅
Размер бинарного файла на типСвоя копия для каждогоОбщий код
Трейт должен быть dyn-совместимым?НетДа
Работает в определениях трейтов✅ (Rust 1.75+)Всегда

Стирание типов с помощью Any и TypeId

Иногда нужно хранить значения неизвестных типов и позже приводить их обратно к конкретному типу. Такой паттерн знаком по void* в C и object в C#. В Rust его даёт std::any::Any:

use std::any::Any;

// Хранение разнородных значений:
fn log_value(value: &dyn Any) {
    if let Some(s) = value.downcast_ref::<String>() {
        println!("String: {s}");
    } else if let Some(n) = value.downcast_ref::<i32>() {
        println!("i32: {n}");
    } else {
        // TypeId позволяет узнать тип во время выполнения:
        println!("Неизвестный тип: {:?}", value.type_id());
    }
}

// Полезно для систем плагинов, шин событий и архитектур в стиле ECS:
struct AnyMap(std::collections::HashMap<std::any::TypeId, Box<dyn Any + Send>>);

impl AnyMap {
    fn new() -> Self { AnyMap(std::collections::HashMap::new()) }

    fn insert<T: Any + Send + 'static>(&mut self, value: T) {
        self.0.insert(std::any::TypeId::of::<T>(), Box::new(value));
    }

    fn get<T: Any + Send + 'static>(&self) -> Option<&T> {
        self.0.get(&std::any::TypeId::of::<T>())?
            .downcast_ref()
    }
}

fn main() {
    let mut map = AnyMap::new();
    map.insert(42_i32);
    map.insert(String::from("hello"));

    assert_eq!(map.get::<i32>(), Some(&42));
    assert_eq!(map.get::<String>().map(|s| s.as_str()), Some("hello"));
    assert_eq!(map.get::<f64>(), None); // Никогда не вставлялось
}

Когда использовать Any: системы плагинов и расширений, карты с индексацией по типам (typemap), приведение ошибок к конкретному типу (anyhow::Error::downcast_ref). Если набор типов известен на этапе компиляции, предпочитайте обобщения или трейт-объекты: Any — это крайняя мера, которая обменивает безопасность на этапе компиляции на гибкость.


Трейты-расширения: добавление методов к чужим типам

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

Этот паттерн широко распространён в экосистеме Rust: itertools::Itertools, futures::StreamExt, tokio::io::AsyncReadExt, tower::ServiceExt.

Проблема

#![allow(unused)]
fn main() {
// Хотим добавить метод .mean() ко всем итераторам, которые выдают f64.
// Но Iterator определён в std, а f64 — примитивный тип, и правило сирот не позволяет:
//
// impl<I: Iterator<Item = f64>> I {   // ❌ Нельзя добавлять собственные методы к чужому типу
//     fn mean(self) -> f64 { ... }
// }
}

Решение: трейт-расширение

#![allow(unused)]
fn main() {
/// Методы-расширения для итераторов по числовым значениям.
pub trait IteratorExt: Iterator {
    /// Вычисляет среднее арифметическое. Для пустых итераторов возвращает `None`.
    fn mean(self) -> Option<f64>
    where
        Self: Sized,
        Self::Item: Into<f64>;
}

// Blanket-реализация — автоматически применяется ко ВСЕМ итераторам
impl<I: Iterator> IteratorExt for I {
    fn mean(self) -> Option<f64>
    where
        Self: Sized,
        Self::Item: Into<f64>,
    {
        let mut sum: f64 = 0.0;
        let mut count: u64 = 0;
        for item in self {
            sum += item.into();
            count += 1;
        }
        if count == 0 { None } else { Some(sum / count as f64) }
    }
}

// Использование — достаточно импортировать трейт:
use crate::IteratorExt;  // Один импорт — и метод появляется у всех итераторов

fn analyze_temperatures(readings: &[f64]) -> Option<f64> {
    readings.iter().copied().mean()  // .mean() теперь доступен!
}

fn analyze_sensor_data(data: &[i32]) -> Option<f64> {
    data.iter().copied().mean()  // Работает и для i32 (i32: Into<f64>)
}
}

Пример из практики: расширения для результатов диагностики

#![allow(unused)]
fn main() {
use std::collections::HashMap;

struct DiagResult {
    component: String,
    passed: bool,
    message: String,
}

/// Трейт-расширение для Vec<DiagResult>: добавляет методы предметного анализа.
pub trait DiagResultsExt {
    fn passed_count(&self) -> usize;
    fn failed_count(&self) -> usize;
    fn overall_pass(&self) -> bool;
    fn failures_by_component(&self) -> HashMap<String, Vec<&DiagResult>>;
}

impl DiagResultsExt for Vec<DiagResult> {
    fn passed_count(&self) -> usize {
        self.iter().filter(|r| r.passed).count()
    }

    fn failed_count(&self) -> usize {
        self.iter().filter(|r| !r.passed).count()
    }

    fn overall_pass(&self) -> bool {
        self.iter().all(|r| r.passed)
    }

    fn failures_by_component(&self) -> HashMap<String, Vec<&DiagResult>> {
        let mut map = HashMap::new();
        for r in self.iter().filter(|r| !r.passed) {
            map.entry(r.component.clone()).or_default().push(r);
        }
        map
    }
}

// Теперь у любого Vec<DiagResult> есть эти методы:
fn report(results: Vec<DiagResult>) {
    if !results.overall_pass() {
        let failures = results.failures_by_component();
        for (component, fails) in &failures {
            eprintln!("{component}: отказов — {}", fails.len());
        }
    }
}
}

Соглашение об именах

В экосистеме Rust принято единообразное суффиксное имя Ext:

КрейтТрейт-расширениеРасширяет
itertoolsItertoolsIterator
futuresStreamExt, FutureExtStream, Future
tokioAsyncReadExt, AsyncWriteExtAsyncRead, AsyncWrite
towerServiceExtService
bytesBufMut (частично)&mut [u8]
Ваш крейтDiagResultsExtVec<DiagResult>

Когда использовать

СитуацияНужен трейт-расширение?
Добавить удобные методы к чужому типу✅
Сгруппировать логику предметной области для обобщённых коллекций✅
Методу нужен доступ к приватным полям❌ (используйте обёртку/newtype)
Метод логически принадлежит новому типу, которым вы управляете❌ (просто добавьте его в свой тип)
Нужно, чтобы метод был доступен без каких-либо импортов❌ (подойдут только собственные методы)

Диспетчеризация через перечисления: статический полиморфизм без dyn

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

Проблема с dyn Trait

#![allow(unused)]
fn main() {
trait Sensor {
    fn read(&self) -> f64;
    fn name(&self) -> &str;
}

struct Gps { lat: f64, lon: f64 }
struct Thermometer { temp_c: f64 }
struct Accelerometer { g_force: f64 }

impl Sensor for Gps {
    fn read(&self) -> f64 { self.lat }
    fn name(&self) -> &str { "GPS" }
}
impl Sensor for Thermometer {
    fn read(&self) -> f64 { self.temp_c }
    fn name(&self) -> &str { "Термометр" }
}
impl Sensor for Accelerometer {
    fn read(&self) -> f64 { self.g_force }
    fn name(&self) -> &str { "Акселерометр" }
}

// Разнородная коллекция через dyn — работает, но имеет свою цену:
fn read_all_dyn(sensors: &[Box<dyn Sensor>]) -> Vec<f64> {
    sensors.iter().map(|s| s.read()).collect()
    // Каждый вызов .read() проходит через косвенное обращение к vtable
    // Каждый Box выделяет память в куче
}
}

Решение через диспетчеризацию по перечислению

// Заменяем трейт-объект перечислением:
enum AnySensor {
    Gps(Gps),
    Thermometer(Thermometer),
    Accelerometer(Accelerometer),
}

impl AnySensor {
    fn read(&self) -> f64 {
        match self {
            AnySensor::Gps(s) => s.read(),
            AnySensor::Thermometer(s) => s.read(),
            AnySensor::Accelerometer(s) => s.read(),
        }
    }

    fn name(&self) -> &str {
        match self {
            AnySensor::Gps(s) => s.name(),
            AnySensor::Thermometer(s) => s.name(),
            AnySensor::Accelerometer(s) => s.name(),
        }
    }
}

// Теперь: без выделения памяти в куче, без vtable, хранится inline
fn read_all(sensors: &[AnySensor]) -> Vec<f64> {
    sensors.iter().map(|s| s.read()).collect()
    // Каждый .read() — это ветка match, компилятор может заинлайнить всё
}

fn main() {
    let sensors = vec![
        AnySensor::Gps(Gps { lat: 47.6, lon: -122.3 }),
        AnySensor::Thermometer(Thermometer { temp_c: 72.5 }),
        AnySensor::Accelerometer(Accelerometer { g_force: 1.02 }),
    ];

    for sensor in &sensors {
        println!("{}: {:.2}", sensor.name(), sensor.read());
    }
}

Реализация трейта для перечисления

Для совместимости можно реализовать исходный трейт и для самого перечисления:

#![allow(unused)]
fn main() {
impl Sensor for AnySensor {
    fn read(&self) -> f64 {
        match self {
            AnySensor::Gps(s) => s.read(),
            AnySensor::Thermometer(s) => s.read(),
            AnySensor::Accelerometer(s) => s.read(),
        }
    }

    fn name(&self) -> &str {
        match self {
            AnySensor::Gps(s) => s.name(),
            AnySensor::Thermometer(s) => s.name(),
            AnySensor::Accelerometer(s) => s.name(),
        }
    }
}

// Теперь AnySensor работает везде, где ожидается Sensor, через обобщения:
fn report<S: Sensor>(s: &S) {
    println!("{}: {:.2}", s.name(), s.read());
}
}

Уменьшение шаблонного кода с помощью макроса

Делегирование в каждой ветке match повторяется. Макрос убирает повторы:

#![allow(unused)]
fn main() {
macro_rules! dispatch_sensor {
    ($self:expr, $method:ident $(, $arg:expr)*) => {
        match $self {
            AnySensor::Gps(s) => s.$method($($arg),*),
            AnySensor::Thermometer(s) => s.$method($($arg),*),
            AnySensor::Accelerometer(s) => s.$method($($arg),*),
        }
    };
}

impl Sensor for AnySensor {
    fn read(&self) -> f64     { dispatch_sensor!(self, read) }
    fn name(&self) -> &str    { dispatch_sensor!(self, name) }
}
}

Для больших проектов крейт enum_dispatch автоматизирует всё это полностью:

#![allow(unused)]
fn main() {
use enum_dispatch::enum_dispatch;

#[enum_dispatch]
trait Sensor {
    fn read(&self) -> f64;
    fn name(&self) -> &str;
}

#[enum_dispatch(Sensor)]
enum AnySensor {
    Gps,
    Thermometer,
    Accelerometer,
}
// Весь код делегирования генерируется автоматически.
}

dyn Trait или диспетчеризация через перечисление: руководство по выбору

Известно ли закрытое множество типов (на этапе компиляции)?
├── ДА  → Предпочтительна диспетчеризация через перечисление (быстрее, без выделения памяти в куче)
│         ├── Мало вариантов (< ~20)?          → перечисление вручную
│         └── Много вариантов или они растут?  → крейт enum_dispatch
└── НЕТ → Нужен dyn Trait (плагины, типы, предоставленные пользователем)
Свойствоdyn TraitДиспетчеризация через перечисление
Стоимость диспетчеризацииКосвенное обращение через vtable (~2 нс)Предсказание ветвлений (~0,3 нс)
Выделение памяти в кучеОбычно (Box)Нет (хранится inline)
Дружелюбность к кэшуНет (переходы по указателям)Да (непрерывное хранение)
Открытость для новых типов✅ (реализовать может любой)❌ (закрытое множество)
Размер кодаОбщийОдна копия на вариант
Трейт должен быть dyn-совместимымДаНет
Добавление вариантаИзменения кода не нужныОбновить перечисление и ветки match

Когда использовать диспетчеризацию через перечисление

СценарийРекомендация
Типы диагностических тестов (CPU, GPU, NIC, память, …)✅ Диспетчеризация через перечисление: закрытое множество, известное на этапе компиляции
Протоколы шин (SPI, I2C, UART, …)✅ Диспетчеризация через перечисление или трейт-конфигурация
Система плагинов (пользователь загружает .so во время выполнения)❌ Используйте dyn Trait
2–3 варианта✅ Диспетчеризация через перечисление вручную
10+ вариантов со многими методами✅ Крейт enum_dispatch
Критичный к производительности внутренний цикл✅ Диспетчеризация через перечисление (устраняет vtable)

Примеси возможностей (capability mixins): ассоциированные типы как композиция без накладных расходов

Разработчики на Ruby компонуют поведение с помощью миксинов: include SomeModule добавляет методы в класс. Трейты Rust с ассоциированными типами, методами по умолчанию и blanket-реализациями дают тот же результат, за исключением следующего:

  • Всё разрешается на этапе компиляции: никаких сюрпризов вида method_missing
  • Каждый ассоциированный тип — это ручка настройки, которая меняет то, что порождают методы по умолчанию
  • Компилятор мономорфизирует каждую комбинацию: никаких накладных расходов на vtable

Проблема: сквозные зависимости от шин

Процедуры аппаратной диагностики используют общие операции: чтение датчика IPMI, переключение линии питания GPIO, измерение температуры по SPI. Но разным диагностикам нужны разные комбинации. В Rust нет иерархий наследования. Передавать каждый дескриптор шины отдельным аргументом функции значит получить громоздкие сигнатуры. Нам нужен способ подмешивать возможности шин по выбору, как блюда из меню à la carte.

Шаг 1: определите трейты-«ингредиенты»

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

#![allow(unused)]
fn main() {
use std::io;

// ── Абстракции шин (трейты, которые предоставляет команда аппаратного обеспечения) ──
pub trait SpiBus {
    fn spi_transfer(&self, tx: &[u8], rx: &mut [u8]) -> io::Result<()>;
}

pub trait I2cBus {
    fn i2c_read(&self, addr: u8, reg: u8, buf: &mut [u8]) -> io::Result<()>;
    fn i2c_write(&self, addr: u8, reg: u8, data: &[u8]) -> io::Result<()>;
}

pub trait GpioPin {
    fn set_high(&self) -> io::Result<()>;
    fn set_low(&self) -> io::Result<()>;
    fn read_level(&self) -> io::Result<bool>;
}

pub trait IpmiBmc {
    fn raw_command(&self, net_fn: u8, cmd: u8, data: &[u8]) -> io::Result<Vec<u8>>;
    fn read_sensor(&self, sensor_id: u8) -> io::Result<f64>;
}

// ── Трейты-ингредиенты: по одному на шину, каждый содержит ассоциированный тип ──
pub trait HasSpi {
    type Spi: SpiBus;
    fn spi(&self) -> &Self::Spi;
}

pub trait HasI2c {
    type I2c: I2cBus;
    fn i2c(&self) -> &Self::I2c;
}

pub trait HasGpio {
    type Gpio: GpioPin;
    fn gpio(&self) -> &Self::Gpio;
}

pub trait HasIpmi {
    type Ipmi: IpmiBmc;
    fn ipmi(&self) -> &Self::Ipmi;
}
}

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

Шаг 2: определите трейты-«примеси»

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

#![allow(unused)]
fn main() {
/// Примесь: диагностика вентиляторов. Нужны I2C (тахометр) и GPIO (разрешение ШИМ)
pub trait FanDiagMixin: HasI2c + HasGpio {
    /// Прочитать обороты вентилятора из микросхемы тахометра по I2C.
    fn read_fan_rpm(&self, fan_id: u8) -> io::Result<u32> {
        let mut buf = [0u8; 2];
        self.i2c().i2c_read(0x48 + fan_id, 0x00, &mut buf)?;
        Ok(u16::from_be_bytes(buf) as u32 * 60) // отсчёты тахометра → об/мин
    }

    /// Включить или выключить ШИМ-выход вентилятора через GPIO.
    fn set_fan_pwm(&self, enable: bool) -> io::Result<()> {
        if enable { self.gpio().set_high() }
        else      { self.gpio().set_low() }
    }

    /// Полная проверка состояния вентилятора: чтение оборотов и проверка порога.
    fn check_fan_health(&self, fan_id: u8, min_rpm: u32) -> io::Result<bool> {
        let rpm = self.read_fan_rpm(fan_id)?;
        Ok(rpm >= min_rpm)
    }
}

/// Примесь: мониторинг температуры. Нужны SPI (АЦП термопары) и IPMI (датчики BMC)
pub trait TempMonitorMixin: HasSpi + HasIpmi {
    /// Прочитать термопару через АЦП на SPI (например, MAX31855).
    fn read_thermocouple(&self) -> io::Result<f64> {
        let mut rx = [0u8; 4];
        self.spi().spi_transfer(&[0x00; 4], &mut rx)?;
        let raw = i32::from_be_bytes(rx) >> 18; // 14 бит, со знаком
        Ok(raw as f64 * 0.25)
    }

    /// Прочитать датчик температуры, которым управляет BMC, через IPMI.
    fn read_bmc_temp(&self, sensor_id: u8) -> io::Result<f64> {
        self.ipmi().read_sensor(sensor_id)
    }

    /// Перекрёстная проверка: показания термопары и BMC должны совпадать в пределах delta.
    fn validate_temps(&self, sensor_id: u8, max_delta: f64) -> io::Result<bool> {
        let tc = self.read_thermocouple()?;
        let bmc = self.read_bmc_temp(sensor_id)?;
        Ok((tc - bmc).abs() <= max_delta)
    }
}

/// Примесь: управление последовательностью питания. Нужны GPIO (включение линии) и IPMI (журнал событий)
pub trait PowerSeqMixin: HasGpio + HasIpmi {
    /// Установить сигнал power-good через GPIO и проверить его через датчик IPMI.
    fn enable_power_rail(&self, sensor_id: u8) -> io::Result<bool> {
        self.gpio().set_high()?;
        std::thread::sleep(std::time::Duration::from_millis(50));
        let voltage = self.ipmi().read_sensor(sensor_id)?;
        Ok(voltage > 0.8) // выше 80% номинала: норма
    }

    /// Снять питание и записать отключение через OEM-команду IPMI.
    fn disable_power_rail(&self) -> io::Result<()> {
        self.gpio().set_low()?;
        // Записать OEM-событие «линия питания отключена» в BMC
        self.ipmi().raw_command(0x2E, 0x01, &[0x00, 0x01])?;
        Ok(())
    }
}
}

Шаг 3: blanket-реализации делают их настоящими «примесями»

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

#![allow(unused)]
fn main() {
impl<T: HasI2c + HasGpio>  FanDiagMixin    for T {}
impl<T: HasSpi  + HasIpmi>  TempMonitorMixin for T {}
impl<T: HasGpio + HasIpmi>  PowerSeqMixin   for T {}
}

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

Шаг 4: соберите продакшен-версию

#![allow(unused)]
fn main() {
// ── Конкретные реализации шин (платформа Linux) ────────────────
struct LinuxSpi  { dev: String }
struct LinuxI2c  { dev: String }
struct SysfsGpio { pin: u32 }
struct IpmiTool  { timeout_secs: u32 }

impl SpiBus for LinuxSpi {
    fn spi_transfer(&self, _tx: &[u8], _rx: &mut [u8]) -> io::Result<()> {
        // ioctl spidev: опущено для краткости
        Ok(())
    }
}
impl I2cBus for LinuxI2c {
    fn i2c_read(&self, _addr: u8, _reg: u8, _buf: &mut [u8]) -> io::Result<()> {
        // ioctl i2c-dev: опущено для краткости
        Ok(())
    }
    fn i2c_write(&self, _addr: u8, _reg: u8, _data: &[u8]) -> io::Result<()> { Ok(()) }
}
impl GpioPin for SysfsGpio {
    fn set_high(&self) -> io::Result<()>  { /* /sys/class/gpio */ Ok(()) }
    fn set_low(&self) -> io::Result<()>   { Ok(()) }
    fn read_level(&self) -> io::Result<bool> { Ok(true) }
}
impl IpmiBmc for IpmiTool {
    fn raw_command(&self, _nf: u8, _cmd: u8, _data: &[u8]) -> io::Result<Vec<u8>> {
        // вызывает ipmitool: опущено для краткости
        Ok(vec![])
    }
    fn read_sensor(&self, _id: u8) -> io::Result<f64> { Ok(25.0) }
}

// ── Продакшен-платформа: все четыре шины ─────────────────────────
struct DiagPlatform {
    spi:  LinuxSpi,
    i2c:  LinuxI2c,
    gpio: SysfsGpio,
    ipmi: IpmiTool,
}

impl HasSpi  for DiagPlatform { type Spi  = LinuxSpi;  fn spi(&self)  -> &LinuxSpi  { &self.spi  } }
impl HasI2c  for DiagPlatform { type I2c  = LinuxI2c;  fn i2c(&self)  -> &LinuxI2c  { &self.i2c  } }
impl HasGpio for DiagPlatform { type Gpio = SysfsGpio; fn gpio(&self) -> &SysfsGpio { &self.gpio } }
impl HasIpmi for DiagPlatform { type Ipmi = IpmiTool;  fn ipmi(&self) -> &IpmiTool  { &self.ipmi } }

// Теперь DiagPlatform имеет ВСЕ методы примесей:
fn production_diagnostics(platform: &DiagPlatform) -> io::Result<()> {
    let rpm = platform.read_fan_rpm(0)?;       // из FanDiagMixin
    let tc  = platform.read_thermocouple()?;   // из TempMonitorMixin
    let ok  = platform.enable_power_rail(42)?; // из PowerSeqMixin
    println!("Вентилятор: {rpm} об/мин, температура: {tc}°C, питание: {ok}");
    Ok(())
}
}

Шаг 5: тестирование на моках (аппаратура не нужна)

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::Cell;

    struct MockSpi  { temp: Cell<f64> }
    struct MockI2c  { rpm: Cell<u32> }
    struct MockGpio { level: Cell<bool> }
    struct MockIpmi { sensor_val: Cell<f64> }

    impl SpiBus for MockSpi {
        fn spi_transfer(&self, _tx: &[u8], rx: &mut [u8]) -> io::Result<()> {
            // Кодируем температуру мока в формате MAX31855
            let raw = ((self.temp.get() / 0.25) as i32) << 18;
            rx.copy_from_slice(&raw.to_be_bytes());
            Ok(())
        }
    }
    impl I2cBus for MockI2c {
        fn i2c_read(&self, _addr: u8, _reg: u8, buf: &mut [u8]) -> io::Result<()> {
            let tach = (self.rpm.get() / 60) as u16;
            buf.copy_from_slice(&tach.to_be_bytes());
            Ok(())
        }
        fn i2c_write(&self, _: u8, _: u8, _: &[u8]) -> io::Result<()> { Ok(()) }
    }
    impl GpioPin for MockGpio {
        fn set_high(&self)  -> io::Result<()>   { self.level.set(true);  Ok(()) }
        fn set_low(&self)   -> io::Result<()>   { self.level.set(false); Ok(()) }
        fn read_level(&self) -> io::Result<bool> { Ok(self.level.get()) }
    }
    impl IpmiBmc for MockIpmi {
        fn raw_command(&self, _: u8, _: u8, _: &[u8]) -> io::Result<Vec<u8>> { Ok(vec![]) }
        fn read_sensor(&self, _: u8) -> io::Result<f64> { Ok(self.sensor_val.get()) }
    }

    // ── Частичная платформа: только шины, нужные вентиляторам ─────
    struct FanTestRig {
        i2c:  MockI2c,
        gpio: MockGpio,
    }
    impl HasI2c  for FanTestRig { type I2c  = MockI2c;  fn i2c(&self)  -> &MockI2c  { &self.i2c  } }
    impl HasGpio for FanTestRig { type Gpio = MockGpio; fn gpio(&self) -> &MockGpio { &self.gpio } }
    // FanTestRig получает FanDiagMixin, но НЕ TempMonitorMixin и НЕ PowerSeqMixin

    #[test]
    fn fan_health_check_passes_above_threshold() {
        let rig = FanTestRig {
            i2c:  MockI2c  { rpm: Cell::new(6000) },
            gpio: MockGpio { level: Cell::new(false) },
        };
        assert!(rig.check_fan_health(0, 4000).unwrap());
    }

    #[test]
    fn fan_health_check_fails_below_threshold() {
        let rig = FanTestRig {
            i2c:  MockI2c  { rpm: Cell::new(2000) },
            gpio: MockGpio { level: Cell::new(false) },
        };
        assert!(!rig.check_fan_health(0, 4000).unwrap());
    }
}
}

Обратите внимание: FanTestRig реализует только HasI2c + HasGpio. Он автоматически получает FanDiagMixin, но компилятор не примет rig.read_thermocouple(), потому что HasSpi не выполнен. Так область действия примесей обеспечивается на этапе компиляции.

Условные методы: то, чего не умеет Ruby

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

#![allow(unused)]
fn main() {
/// Трейт-маркер для SPI-контроллеров с поддержкой DMA
pub trait DmaCapable: SpiBus {
    fn dma_transfer(&self, tx: &[u8], rx: &mut [u8]) -> io::Result<()>;
}

/// Трейт-маркер для GPIO-линий с поддержкой прерываний
pub trait InterruptCapable: GpioPin {
    fn wait_for_edge(&self, timeout_ms: u32) -> io::Result<bool>;
}

pub trait AdvancedDiagMixin: HasSpi + HasGpio {
    // Доступен всегда
    fn basic_probe(&self) -> io::Result<bool> {
        let mut rx = [0u8; 1];
        self.spi().spi_transfer(&[0xFF], &mut rx)?;
        Ok(rx[0] != 0x00)
    }

    // Существует только тогда, когда SPI-контроллер поддерживает DMA
    fn bulk_sensor_read(&self, buf: &mut [u8]) -> io::Result<()>
    where
        Self::Spi: DmaCapable,
    {
        self.spi().dma_transfer(&vec![0x00; buf.len()], buf)
    }

    // Существует только тогда, когда GPIO-линия поддерживает прерывания
    fn wait_for_fault_signal(&self, timeout_ms: u32) -> io::Result<bool>
    where
        Self::Gpio: InterruptCapable,
    {
        self.gpio().wait_for_edge(timeout_ms)
    }
}

impl<T: HasSpi + HasGpio> AdvancedDiagMixin for T {}
}

Если SPI вашей платформы не поддерживает DMA, вызов bulk_sensor_read() — это ошибка компиляции, а не падение во время выполнения. Ближайший аналог — проверка respond_to? в Ruby, но она выполняется во время развёртывания, а не компиляции.

Компонуемость: наложение примесей

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

┌─────────────┐    ┌───────────┐    ┌──────────────┐
│ FanDiagMixin│    │TempMonitor│    │ PowerSeqMixin│
│  (I2C+GPIO) │    │ (SPI+IPMI)│    │  (GPIO+IPMI) │
└──────┬──────┘    └─────┬─────┘    └──────┬───────┘
       │                 │                 │
       │   ┌─────────────┴─────────────┐   │
       └──►│      DiagPlatform         │◄──┘
           │ HasSpi+HasI2c+HasGpio     │
           │        +HasIpmi           │
           └───────────────────────────┘

DiagPlatform реализует HasGpio один раз, а FanDiagMixin и PowerSeqMixin используют один и тот же self.gpio(). В Ruby это были бы два модуля, оба обращающиеся к self.gpio_pin. Но если бы они ожидали разные номера пинов, конфликт обнаружился бы только во время выполнения. В Rust неоднозначность можно разрешить на уровне типов.

Сравнение: миксины Ruby и возможностные миксины Rust

ПараметрМиксины RubyВозможностные миксины Rust
ДиспетчеризацияВо время выполнения (поиск в таблице методов)На этапе компиляции (мономорфизация)
Безопасная композицияЛинеаризация MRO скрывает конфликтыКомпилятор отвергает неоднозначность
Условные методыrespond_to? во время выполненияГраницы where на этапе компиляции
Накладные расходыДиспетчеризация методов и сборка мусораБез накладных расходов (инлайнится)
ТестируемостьЗаглушки и моки через метапрограммированиеОбобщения по типам-моков
Добавление новых шинinclude во время выполненияДобавить трейт-ингредиент и перекомпилировать
Гибкость во время выполненияextend, prepend, открытые классыНет (полностью статично)

Когда использовать возможностные миксины

СценарийИспользовать миксины?
Несколько диагностик используют общую логику чтения шин✅
Тестовому стенду нужны разные подмножества шин✅ (структуры с частичным набором ингредиентов)
Методы допустимы только для определённых возможностей шин (DMA, IRQ)✅ (условные границы where)
Нужна загрузка модулей во время выполнения (плагины)❌ (используйте dyn Trait или диспетчеризацию через перечисление)
Одна структура с одной шиной, разделять нечего❌ (оставьте всё простым)
Ингредиенты из разных крейтов с проблемами когерентности⚠️ (используйте обёртки newtype)

Ключевые выводы: возможностные миксины

  1. Трейт-ингредиент = ассоциированный тип + метод доступа (например, HasSpi)
  2. Трейт-примесь = супертрейты-ингредиенты + тела методов по умолчанию
  3. Blanket-реализация = impl<T: HasX + HasY> Mixin for T {}: автоматически добавляет методы
  4. Условные методы = where Self::Spi: DmaCapable у отдельных методов по умолчанию
  5. Частичные платформы = тестовые структуры, которые реализуют только нужные ингредиенты
  6. Без затрат во время выполнения: компилятор генерирует специализированный код для каждого типа платформы

Типизированные команды: безопасность возвращаемых типов в стиле GADT

В Haskell обобщённые алгебраические типы данных (GADT) позволяют каждому конструктору типа уточнять параметр типа, поэтому Expr Int и Expr Bool различает проверка типов. В Rust нет прямого синтаксиса GADT, но трейты с ассоциированными типами дают ту же гарантию: тип команды определяет тип ответа, а перепутать их — ошибка компиляции.

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

Проблема: болото из нетипизированных Vec<u8>

Большинство стеков IPMI на C/C++ и наивные порты на Rust используют сырые байты повсюду:

#![allow(unused)]
fn main() {
use std::io;

struct BmcConnectionUntyped { timeout_secs: u32 }

impl BmcConnectionUntyped {
    fn raw_command(&self, net_fn: u8, cmd: u8, data: &[u8]) -> io::Result<Vec<u8>> {
        // ... вызывает ipmitool ...
        Ok(vec![0x00, 0x19, 0x00]) // заглушка
    }
}

fn diagnose_thermal_untyped(bmc: &BmcConnectionUntyped) -> io::Result<()> {
    // Читаем температуру CPU — ID датчика 0x20
    let raw = bmc.raw_command(0x04, 0x2D, &[0x20])?;
    let cpu_temp = raw[0] as f64;  // 🤞 надеемся, что байт 0 — это показание

    // Читаем скорость вентилятора — ID датчика 0x30
    let raw = bmc.raw_command(0x04, 0x2D, &[0x30])?;
    let fan_rpm = raw[0] as u32;  // 🐛 ОШИБКА: скорость вентилятора — 2 байта, little-endian

    // Читаем входное напряжение — ID датчика 0x40
    let raw = bmc.raw_command(0x04, 0x2D, &[0x40])?;
    let voltage = raw[0] as f64;  // 🐛 ОШИБКА: нужно делить на 1000

    // 🐛 Сравнение °C с об/мин — компилируется, но бессмысленно
    if cpu_temp > fan_rpm as f64 {
        println!("Ой-ой");
    }

    // 🐛 Передача вольт как температуры — компилируется без проблем
    log_temp_untyped(voltage);
    log_volts_untyped(cpu_temp);

    Ok(())
}

fn log_temp_untyped(t: f64)  { println!("Температура: {t}°C"); }
fn log_volts_untyped(v: f64) { println!("Напряжение: {v}В"); }
}

Каждое показание — это f64: компилятор не знает, что одно из них — температура, другое — обороты, а третье — напряжение. Четыре разные ошибки компилируются без единого предупреждения:

№ОшибкаПоследствиеОбнаружена
1Обороты вентилятора разбираются как 1 байт вместо 2Читается 25 об/мин вместо 6400В продакшене, в 3 часа ночи, потоком сообщений об отказе вентиляторов
2Напряжение не поделено на 100012000 В вместо 12,0 ВПроверка порога помечает каждый блок питания
3Сравнение °C с об/минБессмысленное логическое значениеВозможно, никогда
4Напряжение передано в log_temp_untyped()Тихое повреждение данных в логахЧерез 6 месяцев, при чтении истории

Решение: типизированные команды через ассоциированные типы

Шаг 1: доменные newtype

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
struct Celsius(f64);

#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
struct Rpm(u32);

#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
struct Volts(f64);

#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
struct Watts(f64);
}

Шаг 2: трейт команды (аналог GADT)

Ключевой элемент здесь — ассоциированный тип Response: он связывает каждую команду с её типом результата.

#![allow(unused)]
fn main() {
trait IpmiCmd {
    /// «Индекс» GADT: определяет, что возвращает execute().
    type Response;

    fn net_fn(&self) -> u8;
    fn cmd_byte(&self) -> u8;
    fn payload(&self) -> Vec<u8>;

    /// Разбор ответа инкапсулирован ЗДЕСЬ: каждая команда знает свою раскладку байтов.
    fn parse_response(&self, raw: &[u8]) -> io::Result<Self::Response>;
}
}

Шаг 3: одна структура на команду, разбор записан один раз

#![allow(unused)]
fn main() {
struct ReadTemp { sensor_id: u8 }
impl IpmiCmd for ReadTemp {
    type Response = Celsius;  // ← «эта команда возвращает температуру»
    fn net_fn(&self) -> u8 { 0x04 }
    fn cmd_byte(&self) -> u8 { 0x2D }
    fn payload(&self) -> Vec<u8> { vec![self.sensor_id] }
    fn parse_response(&self, raw: &[u8]) -> io::Result<Celsius> {
        // Знаковый байт по IPMI SDR: написан один раз, протестирован один раз
        Ok(Celsius(raw[0] as i8 as f64))
    }
}

struct ReadFanSpeed { fan_id: u8 }
impl IpmiCmd for ReadFanSpeed {
    type Response = Rpm;     // ← «эта команда возвращает обороты»
    fn net_fn(&self) -> u8 { 0x04 }
    fn cmd_byte(&self) -> u8 { 0x2D }
    fn payload(&self) -> Vec<u8> { vec![self.fan_id] }
    fn parse_response(&self, raw: &[u8]) -> io::Result<Rpm> {
        // 2 байта, little-endian: правильная раскладка, записана один раз
        Ok(Rpm(u16::from_le_bytes([raw[0], raw[1]]) as u32))
    }
}

struct ReadVoltage { rail: u8 }
impl IpmiCmd for ReadVoltage {
    type Response = Volts;   // ← «эта команда возвращает напряжение»
    fn net_fn(&self) -> u8 { 0x04 }
    fn cmd_byte(&self) -> u8 { 0x2D }
    fn payload(&self) -> Vec<u8> { vec![self.rail] }
    fn parse_response(&self, raw: &[u8]) -> io::Result<Volts> {
        // Милливольты → вольты, всегда корректно
        Ok(Volts(u16::from_le_bytes([raw[0], raw[1]]) as f64 / 1000.0))
    }
}

struct ReadFru { fru_id: u8 }
impl IpmiCmd for ReadFru {
    type Response = String;
    fn net_fn(&self) -> u8 { 0x0A }
    fn cmd_byte(&self) -> u8 { 0x11 }
    fn payload(&self) -> Vec<u8> { vec![self.fru_id, 0x00, 0x00, 0xFF] }
    fn parse_response(&self, raw: &[u8]) -> io::Result<String> {
        Ok(String::from_utf8_lossy(raw).to_string())
    }
}
}

Шаг 4: исполнитель (без dyn, мономорфизация)

#![allow(unused)]
fn main() {
struct BmcConnection { timeout_secs: u32 }

impl BmcConnection {
    /// Обобщённая по любой команде: компилятор создаёт одну версию на каждый тип команды.
    fn execute<C: IpmiCmd>(&self, cmd: &C) -> io::Result<C::Response> {
        let raw = self.raw_send(cmd.net_fn(), cmd.cmd_byte(), &cmd.payload())?;
        cmd.parse_response(&raw)
    }

    fn raw_send(&self, _nf: u8, _cmd: u8, _data: &[u8]) -> io::Result<Vec<u8>> {
        Ok(vec![0x19, 0x00]) // заглушка: настоящая реализация вызывает ipmitool
    }
}
}

Шаг 5: код вызывающей стороны — все четыре ошибки становятся ошибками компиляции

#![allow(unused)]
fn main() {
fn diagnose_thermal(bmc: &BmcConnection) -> io::Result<()> {
    let cpu_temp: Celsius = bmc.execute(&ReadTemp { sensor_id: 0x20 })?;
    let fan_rpm:  Rpm     = bmc.execute(&ReadFanSpeed { fan_id: 0x30 })?;
    let voltage:  Volts   = bmc.execute(&ReadVoltage { rail: 0x40 })?;

    // Ошибка №1 — НЕВОЗМОЖНА: разбор находится в ReadFanSpeed::parse_response
    // Ошибка №2 — НЕВОЗМОЖНА: масштабирование находится в ReadVoltage::parse_response

    // Ошибка №3 — ОШИБКА КОМПИЛЯЦИИ:
    // if cpu_temp > fan_rpm { }
    //    ^^^^^^^^   ^^^^^^^
    //    Celsius    Rpm      → "mismatched types" ❌

    // Ошибка №4 — ОШИБКА КОМПИЛЯЦИИ:
    // log_temperature(voltage);
    //                 ^^^^^^^  Volts, expected Celsius ❌

    // Компилируются только корректные сравнения:
    if cpu_temp > Celsius(85.0) {
        println!("ЦП перегревается: {:?}", cpu_temp);
    }
    if fan_rpm < Rpm(4000) {
        println!("Вентилятор слишком медленный: {:?}", fan_rpm);
    }

    Ok(())
}

fn log_temperature(t: Celsius) { println!("Температура: {:?}", t); }
fn log_voltage(v: Volts)       { println!("Напряжение: {:?}", v); }
}

Макро-DSL для диагностических сценариев

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

#![allow(unused)]
fn main() {
/// Выполняет последовательность типизированных команд IPMI и возвращает кортеж результатов.
/// Каждый элемент кортежа имеет собственный тип Response команды.
macro_rules! diag_script {
    ($bmc:expr; $($cmd:expr),+ $(,)?) => {{
        ( $( $bmc.execute(&$cmd)?, )+ )
    }};
}

fn full_pre_flight(bmc: &BmcConnection) -> io::Result<()> {
    // Раскрывается в: (Celsius, Rpm, Volts, String). Каждый тип отслеживается
    let (temp, rpm, volts, board_pn) = diag_script!(bmc;
        ReadTemp     { sensor_id: 0x20 },
        ReadFanSpeed { fan_id:    0x30 },
        ReadVoltage  { rail:      0x40 },
        ReadFru      { fru_id:    0x00 },
    );

    println!("Плата: {:?}", board_pn);
    println!("ЦП: {:?}, вентилятор: {:?}, 12 В: {:?}", temp, rpm, volts);

    // Проверки порогов с учётом типов:
    assert!(temp  < Celsius(95.0), "ЦП слишком горячий");
    assert!(rpm   > Rpm(3000),     "Вентилятор слишком медленный");
    assert!(volts > Volts(11.4),   "Линия 12 В просела");

    Ok(())
}
}

Макрос — это всего лишь синтаксический сахар: тип кортежа (Celsius, Rpm, Volts, String) полностью выводится компилятором. Поменяйте местами две команды, и деструктуризация сломается на этапе компиляции, а не во время выполнения.

Диспетчеризация через перечисление для разнородных списков команд

Когда нужен Vec из команд разных типов (например, настраиваемый сценарий, загруженный из JSON), используйте диспетчеризацию через перечисление, чтобы обойтись без dyn:

#![allow(unused)]
fn main() {
enum AnyReading {
    Temp(Celsius),
    Rpm(Rpm),
    Volt(Volts),
    Text(String),
}

enum AnyCmd {
    Temp(ReadTemp),
    Fan(ReadFanSpeed),
    Voltage(ReadVoltage),
    Fru(ReadFru),
}

impl AnyCmd {
    fn execute(&self, bmc: &BmcConnection) -> io::Result<AnyReading> {
        match self {
            AnyCmd::Temp(c)    => Ok(AnyReading::Temp(bmc.execute(c)?)),
            AnyCmd::Fan(c)     => Ok(AnyReading::Rpm(bmc.execute(c)?)),
            AnyCmd::Voltage(c) => Ok(AnyReading::Volt(bmc.execute(c)?)),
            AnyCmd::Fru(c)     => Ok(AnyReading::Text(bmc.execute(c)?)),
        }
    }
}

/// Динамический диагностический сценарий: команды загружаются во время выполнения
fn run_script(bmc: &BmcConnection, script: &[AnyCmd]) -> io::Result<Vec<AnyReading>> {
    script.iter().map(|cmd| cmd.execute(bmc)).collect()
}
}

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

Тестирование типизированных команд

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    struct StubBmc {
        responses: std::collections::HashMap<u8, Vec<u8>>,
    }

    impl StubBmc {
        fn execute<C: IpmiCmd>(&self, cmd: &C) -> io::Result<C::Response> {
            let key = cmd.payload()[0]; // ID датчика как ключ
            let raw = self.responses.get(&key)
                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "нет заглушки"))?;
            cmd.parse_response(raw)
        }
    }

    #[test]
    fn read_temp_parses_signed_byte() {
        let bmc = StubBmc {
            responses: [( 0x20, vec![0xE7] )].into() // -25 как i8 = 0xE7
        };
        let temp = bmc.execute(&ReadTemp { sensor_id: 0x20 }).unwrap();
        assert_eq!(temp, Celsius(-25.0));
    }

    #[test]
    fn read_fan_parses_two_byte_le() {
        let bmc = StubBmc {
            responses: [( 0x30, vec![0x00, 0x19] )].into() // 0x1900 = 6400
        };
        let rpm = bmc.execute(&ReadFanSpeed { fan_id: 0x30 }).unwrap();
        assert_eq!(rpm, Rpm(6400));
    }

    #[test]
    fn read_voltage_scales_millivolts() {
        let bmc = StubBmc {
            responses: [( 0x40, vec![0xE8, 0x2E] )].into() // 0x2EE8 = 12008 мВ
        };
        let v = bmc.execute(&ReadVoltage { rail: 0x40 }).unwrap();
        assert!((v.0 - 12.008).abs() < 0.001);
    }
}
}

Разбор каждой команды тестируется независимо. Если ReadFanSpeed в новой редакции спецификации IPMI перейдёт с 2-байтового little-endian на 4-байтовый big-endian, вы обновите один parse_response, а тест поймает регрессии.

Как это соотносится с GADT в Haskell

GADT в Haskell                       Аналог в Rust
────────────────                     ───────────────────────
data Cmd a where                     trait IpmiCmd {
  ReadTemp :: SensorId -> Cmd Temp       type Response;
  ReadFan  :: FanId    -> Cmd Rpm        ...
                                     }

eval :: Cmd a -> IO a                fn execute<C: IpmiCmd>(&self, cmd: &C)
                                         -> io::Result<C::Response>

Уточнение типа в ветках case         Мономорфизация: компилятор генерирует
                                     execute::<ReadTemp>() → возвращает Celsius
                                     execute::<ReadFanSpeed>() → возвращает Rpm

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

Сводка: до и после

ПараметрНетипизированный (Vec<u8>)Типизированные команды
Строк на датчик~3 (дублируются в каждом месте вызова)~15 (написаны и протестированы один раз)
Возможные ошибки разбораВ каждом месте вызоваВ одной реализации parse_response
Ошибки путаницы единицБез ограниченийНет (ошибка компиляции)
Добавление нового датчикаПравка N файлов, копирование разбораДобавить 1 структуру и 1 реализацию
Затраты во время выполнения—Такие же (мономорфизация)
Автодополнение в IDEf64 вездеCelsius, Rpm, Volts: код документирует себя
Нагрузка на код-ревьюНужно проверить разбор каждого сырого байтаПроверить по одной parse_response на датчик
Макро-DSLНеприменимоdiag_script!(bmc; ReadTemp{..}, ReadFan{..}) → (Celsius, Rpm)
Динамические сценарииРучная диспетчеризацияПеречисление AnyCmd, по-прежнему без dyn

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

СценарийРекомендация
Чтение датчиков IPMI с разными физическими единицами✅ Типизированные команды
Карта регистров с полями разной ширины✅ Типизированные команды
Сетевые сообщения протокола (запрос → ответ)✅ Типизированные команды
Один тип команды с одним форматом ответа❌ Избыточно: верните тип напрямую
Прототипирование или изучение неизвестного устройства❌ Сначала сырые байты, типы позже
Система плагинов, где команды неизвестны на этапе компиляции⚠️ Используйте диспетчеризацию через перечисление AnyCmd

Ключевые выводы: трейты

  • Ассоциированные типы = одна реализация на тип; обобщённые параметры = много реализаций на тип
  • GAT открывают путь к lending-итераторам и паттернам async в трейтах
  • Для закрытых множеств используйте диспетчеризацию через перечисление (быстро), для открытых: dyn Trait (гибко)
  • Any + TypeId — запасной выход, когда типы неизвестны на этапе компиляции

См. также: гл. 1 — Обобщённые типы — мономорфизация и случаи, когда обобщения раздувают код. гл. 3 — Newtype и type-state — использование трейтов в паттерне конфигурационного трейта.


Упражнение: репозиторий с ассоциированными типами ★★★ (~40 минут)

Спроектируйте трейт Repository с ассоциированными типами Error, Id и Item. Реализуйте его для хранилища в памяти и продемонстрируйте безопасность типов на этапе компиляции.

🔑 Решение
use std::collections::HashMap;

trait Repository {
    type Item;
    type Id;
    type Error;

    fn get(&self, id: &Self::Id) -> Result<Option<&Self::Item>, Self::Error>;
    fn insert(&mut self, item: Self::Item) -> Result<Self::Id, Self::Error>;
    fn delete(&mut self, id: &Self::Id) -> Result<bool, Self::Error>;
}

#[derive(Debug, Clone)]
struct User {
    name: String,
    email: String,
}

struct InMemoryUserRepo {
    data: HashMap<u64, User>,
    next_id: u64,
}

impl InMemoryUserRepo {
    fn new() -> Self {
        InMemoryUserRepo { data: HashMap::new(), next_id: 1 }
    }
}

impl Repository for InMemoryUserRepo {
    type Item = User;
    type Id = u64;
    type Error = std::convert::Infallible;

    fn get(&self, id: &u64) -> Result<Option<&User>, Self::Error> {
        Ok(self.data.get(id))
    }

    fn insert(&mut self, item: User) -> Result<u64, Self::Error> {
        let id = self.next_id;
        self.next_id += 1;
        self.data.insert(id, item);
        Ok(id)
    }

    fn delete(&mut self, id: &u64) -> Result<bool, Self::Error> {
        Ok(self.data.remove(id).is_some())
    }
}

fn create_and_fetch<R: Repository>(repo: &mut R, item: R::Item) -> Result<(), R::Error>
where
    R::Item: std::fmt::Debug,
    R::Id: std::fmt::Debug,
{
    let id = repo.insert(item)?;
    println!("Вставлено с id: {id:?}");
    let retrieved = repo.get(&id)?;
    println!("Получено: {retrieved:?}");
    Ok(())
}

fn main() {
    let mut repo = InMemoryUserRepo::new();
    create_and_fetch(&mut repo, User {
        name: "Alice".into(),
        email: "alice@example.com".into(),
    }).unwrap();
}

3. Паттерны newtype и type-state 🟡

Что вы узнаете:

  • Паттерн newtype для безопасности типов на этапе компиляции без накладных расходов
  • Паттерн type-state: как сделать недопустимые переходы между состояниями невыразимыми
  • Builder-паттерн с type-state для конструирования, которое проверяет компилятор
  • Паттерн конфигурационного трейта для борьбы с разрастанием обобщённых параметров

Newtype: безопасность типов без накладных расходов

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

#![allow(unused)]
fn main() {
// Без newtype легко перепутать аргументы:
fn create_user(name: String, email: String, age: u32, employee_id: u32) { }
// create_user(name, email, age, id);  — а что, если поменять местами age и id?
// create_user(name, email, id, age);  — КОМПИЛИРУЕТСЯ БЕЗ ОШИБОК, НО ЭТО БАГ

// С newtype компилятор ловит ошибки:
struct UserName(String);
struct Email(String);
struct Age(u32);
struct EmployeeId(u32);

fn create_user(name: UserName, email: Email, age: Age, id: EmployeeId) { }
// create_user(name, email, EmployeeId(42), Age(30));
// ❌ Compile error: expected Age, got EmployeeId
}

impl Deref для newtype: возможности и подводные камни

Реализация Deref для newtype позволяет автоматически приводить его к ссылке на внутренний тип и получать все методы внутреннего типа «бесплатно»:

#![allow(unused)]
fn main() {
use std::ops::Deref;

struct Email(String);

impl Email {
    fn new(raw: &str) -> Result<Self, &'static str> {
        if raw.contains('@') {
            Ok(Email(raw.to_string()))
        } else {
            Err("некорректный email: нет символа @")
        }
    }
}

impl Deref for Email {
    type Target = str;
    fn deref(&self) -> &str { &self.0 }
}

// Теперь Email автоматически разыменовывается в &str:
let email = Email::new("user@example.com").unwrap();
println!("Длина: {}", email.len()); // Использует str::len через Deref
}

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

Когда Deref УМЕСТЕН

СценарийПримерПочему это нормально
Обёртки-умные указателиBox<T>, Arc<T>, MutexGuard<T>Единственная задача обёртки — вести себя как T
Прозрачные «тонкие» обёрткиString → str, PathBuf → Path, Vec<T> → [T]Обёртка является расширенной версией целевого типа
Ваш newtype по сути и есть внутренний типstruct Hostname(String), где всегда нужны все строковые операцииОграничение API не принесёт пользы

Когда Deref — антипаттерн

СценарийПроблема
Доменные типы с инвариантамиEmail разыменовывается в &str, поэтому вызывающий код может вызвать .split_at(), .trim() и т. д. Ни один из них не сохраняет инвариант «должен содержать @». Если кто-то сохранит обрезанный &str и соберёт объект заново, инвариант будет потерян.
Типы, для которых нужен ограниченный APIstruct Password(String) с Deref<Target = str> открывает .as_bytes(), .chars() и вывод Debug: ровно то, что вы пытаетесь скрыть.
Имитация наследованияИспользование Deref, чтобы ManagerWidget автоматически разыменовывался в Widget, имитирует наследование из ООП. Это прямо не рекомендуется: см. Rust API Guidelines (C-DEREF).

Практическое правило: если ваш newtype существует, чтобы добавить безопасность типов или ограничить API, не реализуйте Deref. Если же он нужен, чтобы добавить возможности, сохранив полный интерфейс внутреннего типа (как умный указатель), Deref будет правильным выбором.

DerefMut удваивает риск

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

#![allow(unused)]
fn main() {
use std::ops::{Deref, DerefMut};

struct PortNumber(u16);

impl Deref for PortNumber {
    type Target = u16;
    fn deref(&self) -> &u16 { &self.0 }
}

impl DerefMut for PortNumber {
    fn deref_mut(&mut self) -> &mut u16 { &mut self.0 }
}

let mut port = PortNumber(443);
*port = 0; // Обходит любую проверку — теперь порт некорректен
}

Реализуйте DerefMut только тогда, когда у внутреннего типа нет инвариантов, которые нужно защищать.

Лучше использовать явное делегирование

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

#![allow(unused)]
fn main() {
struct Email(String);

impl Email {
    fn new(raw: &str) -> Result<Self, &'static str> {
        if raw.contains('@') { Ok(Email(raw.to_string())) }
        else { Err("нет символа @") }
    }

    // Открываем только то, что имеет смысл:
    pub fn as_str(&self) -> &str { &self.0 }
    pub fn len(&self) -> usize { self.0.len() }
    pub fn domain(&self) -> &str {
        self.0.split('@').nth(1).unwrap_or("")
    }
    // .split_at(), .trim(), .replace() — НЕ открыты
}
}

Clippy и экосистема

  • clippy::wrong_self_convention может сработать, когда приведение через Deref делает разрешение методов неожиданным (например, is_empty() разрешается в версию внутреннего типа, а не в ту, которую вы хотели переопределить).
  • Rust API Guidelines (C-DEREF) утверждают: «только умные указатели должны реализовывать Deref». Считайте это сильным правилом по умолчанию и отступайте от него, только имея веское обоснование.
  • Если нужна совместимость с трейтами (например, передать Email в функции, которые ожидают &str), рассмотрите реализацию AsRef<str> и Borrow<str>. Это явные преобразования без сюрпризов автоматического приведения.

Матрица решений

Нужны ли ВСЕ методы внутреннего типа?
  ├─ ДА → Использует ли ваш тип инварианты или ограничивает API?
  │    ├─ НЕТ → impl Deref ✅  (умный указатель / прозрачная обёртка)
  │    └─ ДА  → Не реализуйте Deref ❌ (инвариант протекает)
  └─ НЕТ → Не реализуйте Deref ❌  (используйте AsRef / явное делегирование)

Type-State: принуждение к протоколу на этапе компиляции

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

stateDiagram-v2
    [*] --> Disconnected: new()
    Disconnected --> Connected: connect()
    Connected --> Authenticated: authenticate()
    Authenticated --> Authenticated: request()
    Authenticated --> [*]: drop

    Disconnected --> Disconnected: ❌ request() не скомпилируется
    Connected --> Connected: ❌ request() не скомпилируется

Каждый переход поглощает self и возвращает новый тип. Компилятор следит за правильным порядком.

// Проблема: сетевое соединение должно пройти шаги:
// 1. Создание
// 2. Подключение
// 3. Аутентификация
// 4. И только потом использование для запросов
// Вызов request() до authenticate() должен быть ОШИБКОЙ КОМПИЛЯЦИИ.

// --- Маркеры состояний (типы нулевого размера) ---
struct Disconnected;
struct Connected;
struct Authenticated;

// --- Соединение, параметризованное состоянием ---
struct Connection<State> {
    address: String,
    _state: std::marker::PhantomData<State>,
}

// Только отключённые соединения могут подключаться:
impl Connection<Disconnected> {
    fn new(address: &str) -> Self {
        Connection {
            address: address.to_string(),
            _state: std::marker::PhantomData,
        }
    }

    fn connect(self) -> Connection<Connected> {
        println!("Подключение к {}...", self.address);
        Connection {
            address: self.address,
            _state: std::marker::PhantomData,
        }
    }
}

// Только подключённые соединения могут аутентифицироваться:
impl Connection<Connected> {
    fn authenticate(self, _token: &str) -> Connection<Authenticated> {
        println!("Аутентификация...");
        Connection {
            address: self.address,
            _state: std::marker::PhantomData,
        }
    }
}

// Только аутентифицированные соединения могут отправлять запросы:
impl Connection<Authenticated> {
    fn request(&self, path: &str) -> String {
        format!("GET {} с {}", path, self.address)
    }
}

fn main() {
    let conn = Connection::new("api.example.com");
    // conn.request("/data"); // ❌ Compile error: no method `request` on Connection<Disconnected>

    let conn = conn.connect();
    // conn.request("/data"); // ❌ Compile error: no method `request` on Connection<Connected>

    let conn = conn.authenticate("secret-token");
    let response = conn.request("/data"); // ✅ Работает только после аутентификации
    println!("{response}");
}

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

Сравнение с C++ и C#: там это обеспечивают проверками во время выполнения (if (!authenticated) throw ...). Паттерн type-state переносит такие проверки на этап компиляции: недопустимые состояния буквально невыразимы в системе типов.

Builder-паттерн с type-state

Практическое применение: builder, который требует заполнения обязательных полей.

use std::marker::PhantomData;

// Маркерные типы для обязательных полей
struct NeedsName;
struct NeedsPort;
struct Ready;

struct ServerConfig<State> {
    name: Option<String>,
    port: Option<u16>,
    max_connections: usize, // Необязательное поле, есть значение по умолчанию
    _state: PhantomData<State>,
}

impl ServerConfig<NeedsName> {
    fn new() -> Self {
        ServerConfig {
            name: None,
            port: None,
            max_connections: 100,
            _state: PhantomData,
        }
    }

    fn name(self, name: &str) -> ServerConfig<NeedsPort> {
        ServerConfig {
            name: Some(name.to_string()),
            port: self.port,
            max_connections: self.max_connections,
            _state: PhantomData,
        }
    }
}

impl ServerConfig<NeedsPort> {
    fn port(self, port: u16) -> ServerConfig<Ready> {
        ServerConfig {
            name: self.name,
            port: Some(port),
            max_connections: self.max_connections,
            _state: PhantomData,
        }
    }
}

impl ServerConfig<Ready> {
    fn max_connections(mut self, n: usize) -> Self {
        self.max_connections = n;
        self
    }

    fn build(self) -> Server {
        Server {
            name: self.name.unwrap(),
            port: self.port.unwrap(),
            max_connections: self.max_connections,
        }
    }
}

struct Server {
    name: String,
    port: u16,
    max_connections: usize,
}

fn main() {
    // Сначала обязательно задаём имя, затем порт, и только после этого можно собирать:
    let server = ServerConfig::new()
        .name("my-server")
        .port(8080)
        .max_connections(500)
        .build();

    // ServerConfig::new().port(8080); // ❌ Compile error: no method `port` on NeedsName
    // ServerConfig::new().name("x").build(); // ❌ Compile error: no method `build` on NeedsPort
}

Пример: типобезопасный пул соединений

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

stateDiagram-v2
    [*] --> Idle: pool.acquire()
    Idle --> Active: conn.begin_transaction()
    Active --> Active: conn.execute(query)
    Active --> Idle: conn.commit() / conn.rollback()
    Idle --> [*]: pool.release(conn)

    Active --> [*]: ❌ нельзя освободить посреди транзакции
use std::marker::PhantomData;

// Состояния
struct Idle;
struct InTransaction;

struct PooledConnection<State> {
    id: u32,
    _state: PhantomData<State>,
}

struct Pool {
    next_id: u32,
}

impl Pool {
    fn new() -> Self { Pool { next_id: 0 } }

    fn acquire(&mut self) -> PooledConnection<Idle> {
        self.next_id += 1;
        println!("[pool] Получено соединение #{}", self.next_id);
        PooledConnection { id: self.next_id, _state: PhantomData }
    }

    // Освобождать можно только простаивающие соединения: это предотвращает утечки посреди транзакции
    fn release(&self, conn: PooledConnection<Idle>) {
        println!("[pool] Возвращено соединение #{}", conn.id);
    }
}

impl PooledConnection<Idle> {
    fn begin_transaction(self) -> PooledConnection<InTransaction> {
        println!("[conn #{}] BEGIN", self.id);
        PooledConnection { id: self.id, _state: PhantomData }
    }
}

impl PooledConnection<InTransaction> {
    fn execute(&self, query: &str) {
        println!("[conn #{}] EXEC: {}", self.id, query);
    }

    fn commit(self) -> PooledConnection<Idle> {
        println!("[conn #{}] COMMIT", self.id);
        PooledConnection { id: self.id, _state: PhantomData }
    }

    fn rollback(self) -> PooledConnection<Idle> {
        println!("[conn #{}] ROLLBACK", self.id);
        PooledConnection { id: self.id, _state: PhantomData }
    }
}

fn main() {
    let mut pool = Pool::new();

    let conn = pool.acquire();
    let conn = conn.begin_transaction();
    conn.execute("INSERT INTO users VALUES ('Alice')");
    conn.execute("INSERT INTO orders VALUES (1, 42)");
    let conn = conn.commit(); // Снова Idle
    pool.release(conn);       // ✅ Работает только с соединениями в состоянии Idle

    // pool.release(conn_active); // ❌ Compile error: can't release InTransaction
}

Почему это важно на практике: соединение, утёкшее посреди транзакции, удерживает блокировки базы данных бесконечно. Паттерн typestate делает такую утечку невозможной: вернуть соединение в пул нельзя, пока транзакция не будет зафиксирована (commit) или откатана (rollback).


Паттерн конфигурационного трейта: укрощаем разрастание обобщённых параметров

Проблема

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

#![allow(unused)]
fn main() {
trait SpiBus   { fn spi_transfer(&self, tx: &[u8], rx: &mut [u8]) -> Result<(), BusError>; }
trait ComPort  { fn com_send(&self, data: &[u8]) -> Result<usize, BusError>; }
trait I3cBus   { fn i3c_read(&self, addr: u8, buf: &mut [u8]) -> Result<(), BusError>; }
trait SmBus    { fn smbus_read_byte(&self, addr: u8, cmd: u8) -> Result<u8, BusError>; }
trait GpioBus  { fn gpio_set(&self, pin: u32, high: bool); }

// ❌ Каждый новый трейт шины добавляет ещё один обобщённый параметр
struct DiagController<S: SpiBus, C: ComPort, I: I3cBus, M: SmBus, G: GpioBus> {
    spi: S,
    com: C,
    i3c: I,
    smbus: M,
    gpio: G,
}
// Блоки impl, сигнатуры функций и вызывающий код повторяют весь список.
// Добавление шестой шины потребует правки каждого упоминания DiagController<S, C, I, M, G>.
}

Это часто называют «взрывом обобщённых параметров» (generic parameter explosion). Он усугубляется в блоках impl, параметрах функций и у потребителей, каждый из которых вынужден повторять полный список параметров.

Решение: конфигурационный трейт

Объедините все ассоциированные типы в один трейт. Тогда у структуры будет один обобщённый параметр, сколько бы компонентов она ни содержала:

#![allow(unused)]
fn main() {
#[derive(Debug)]
enum BusError {
    Timeout,
    NakReceived,
    HardwareFault(String),
}

// --- Трейты шин (без изменений) ---
trait SpiBus {
    fn spi_transfer(&self, tx: &[u8], rx: &mut [u8]) -> Result<(), BusError>;
    fn spi_write(&self, data: &[u8]) -> Result<(), BusError>;
}

trait ComPort {
    fn com_send(&self, data: &[u8]) -> Result<usize, BusError>;
    fn com_recv(&self, buf: &mut [u8], timeout_ms: u32) -> Result<usize, BusError>;
}

trait I3cBus {
    fn i3c_read(&self, addr: u8, buf: &mut [u8]) -> Result<(), BusError>;
    fn i3c_write(&self, addr: u8, data: &[u8]) -> Result<(), BusError>;
}

// --- Конфигурационный трейт: один ассоциированный тип на компонент ---
trait BoardConfig {
    type Spi: SpiBus;
    type Com: ComPort;
    type I3c: I3cBus;
}

// --- У DiagController ровно ОДИН обобщённый параметр ---
struct DiagController<Cfg: BoardConfig> {
    spi: Cfg::Spi,
    com: Cfg::Com,
    i3c: Cfg::I3c,
}
}

DiagController<Cfg> больше никогда не получит новый обобщённый параметр. Добавление четвёртой шины означает добавление одного ассоциированного типа в BoardConfig и одного поля в DiagController, без изменения зависимых сигнатур.

Реализация контроллера

#![allow(unused)]
fn main() {
impl<Cfg: BoardConfig> DiagController<Cfg> {
    fn new(spi: Cfg::Spi, com: Cfg::Com, i3c: Cfg::I3c) -> Self {
        DiagController { spi, com, i3c }
    }

    fn read_flash_id(&self) -> Result<u32, BusError> {
        let cmd = [0x9F]; // JEDEC Read ID
        let mut id = [0u8; 4];
        self.spi.spi_transfer(&cmd, &mut id)?;
        Ok(u32::from_be_bytes(id))
    }

    fn send_bmc_command(&self, cmd: &[u8]) -> Result<Vec<u8>, BusError> {
        self.com.com_send(cmd)?;
        let mut resp = vec![0u8; 256];
        let n = self.com.com_recv(&mut resp, 1000)?;
        resp.truncate(n);
        Ok(resp)
    }

    fn read_sensor_temp(&self, sensor_addr: u8) -> Result<i16, BusError> {
        let mut buf = [0u8; 2];
        self.i3c.i3c_read(sensor_addr, &mut buf)?;
        Ok(i16::from_be_bytes(buf))
    }

    fn run_full_diag(&self) -> Result<DiagReport, BusError> {
        let flash_id = self.read_flash_id()?;
        let bmc_resp = self.send_bmc_command(b"VERSION\n")?;
        let cpu_temp = self.read_sensor_temp(0x48)?;
        let gpu_temp = self.read_sensor_temp(0x49)?;

        Ok(DiagReport {
            flash_id,
            bmc_version: String::from_utf8_lossy(&bmc_resp).to_string(),
            cpu_temp_c: cpu_temp,
            gpu_temp_c: gpu_temp,
        })
    }
}

#[derive(Debug)]
struct DiagReport {
    flash_id: u32,
    bmc_version: String,
    cpu_temp_c: i16,
    gpu_temp_c: i16,
}
}

Сборка для продакшена

Один impl BoardConfig выбирает конкретные драйверы оборудования:

struct PlatformSpi  { dev: String, speed_hz: u32 }
struct UartCom      { dev: String, baud: u32 }
struct LinuxI3c     { dev: String }

impl SpiBus for PlatformSpi {
    fn spi_transfer(&self, tx: &[u8], rx: &mut [u8]) -> Result<(), BusError> {
        // ioctl(SPI_IOC_MESSAGE) в продакшене
        rx[0..4].copy_from_slice(&[0xEF, 0x40, 0x18, 0x00]);
        Ok(())
    }
    fn spi_write(&self, _data: &[u8]) -> Result<(), BusError> { Ok(()) }
}

impl ComPort for UartCom {
    fn com_send(&self, _data: &[u8]) -> Result<usize, BusError> { Ok(0) }
    fn com_recv(&self, buf: &mut [u8], _timeout: u32) -> Result<usize, BusError> {
        let resp = b"BMC v2.4.1\n";
        buf[..resp.len()].copy_from_slice(resp);
        Ok(resp.len())
    }
}

impl I3cBus for LinuxI3c {
    fn i3c_read(&self, _addr: u8, buf: &mut [u8]) -> Result<(), BusError> {
        buf[0] = 0x00; buf[1] = 0x2D; // 45°C
        Ok(())
    }
    fn i3c_write(&self, _addr: u8, _data: &[u8]) -> Result<(), BusError> { Ok(()) }
}

// ✅ Одна структура, одна реализация: все конкретные типы определяются здесь
struct ProductionBoard;
impl BoardConfig for ProductionBoard {
    type Spi = PlatformSpi;
    type Com = UartCom;
    type I3c = LinuxI3c;
}

fn main() {
    let ctrl = DiagController::<ProductionBoard>::new(
        PlatformSpi { dev: "/dev/spidev0.0".into(), speed_hz: 10_000_000 },
        UartCom     { dev: "/dev/ttyS0".into(),     baud: 115200 },
        LinuxI3c    { dev: "/dev/i3c-0".into() },
    );
    let report = ctrl.run_full_diag().unwrap();
    println!("{report:#?}");
}

Подключение тестов с моками

Замените весь аппаратный слой, определив другой BoardConfig:

#![allow(unused)]
fn main() {
struct MockSpi  { flash_id: [u8; 4] }
struct MockCom  { response: Vec<u8> }
struct MockI3c  { temps: std::collections::HashMap<u8, i16> }

impl SpiBus for MockSpi {
    fn spi_transfer(&self, _tx: &[u8], rx: &mut [u8]) -> Result<(), BusError> {
        rx[..4].copy_from_slice(&self.flash_id);
        Ok(())
    }
    fn spi_write(&self, _data: &[u8]) -> Result<(), BusError> { Ok(()) }
}

impl ComPort for MockCom {
    fn com_send(&self, _data: &[u8]) -> Result<usize, BusError> { Ok(0) }
    fn com_recv(&self, buf: &mut [u8], _timeout: u32) -> Result<usize, BusError> {
        let n = self.response.len().min(buf.len());
        buf[..n].copy_from_slice(&self.response[..n]);
        Ok(n)
    }
}

impl I3cBus for MockI3c {
    fn i3c_read(&self, addr: u8, buf: &mut [u8]) -> Result<(), BusError> {
        let temp = self.temps.get(&addr).copied().unwrap_or(0);
        buf[..2].copy_from_slice(&temp.to_be_bytes());
        Ok(())
    }
    fn i3c_write(&self, _addr: u8, _data: &[u8]) -> Result<(), BusError> { Ok(()) }
}

struct TestBoard;
impl BoardConfig for TestBoard {
    type Spi = MockSpi;
    type Com = MockCom;
    type I3c = MockI3c;
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_test_controller() -> DiagController<TestBoard> {
        let mut temps = std::collections::HashMap::new();
        temps.insert(0x48, 45i16);
        temps.insert(0x49, 72i16);

        DiagController::<TestBoard>::new(
            MockSpi  { flash_id: [0xEF, 0x40, 0x18, 0x00] },
            MockCom  { response: b"BMC v2.4.1\n".to_vec() },
            MockI3c  { temps },
        )
    }

    #[test]
    fn test_flash_id() {
        let ctrl = make_test_controller();
        assert_eq!(ctrl.read_flash_id().unwrap(), 0xEF401800);
    }

    #[test]
    fn test_sensor_temps() {
        let ctrl = make_test_controller();
        assert_eq!(ctrl.read_sensor_temp(0x48).unwrap(), 45);
        assert_eq!(ctrl.read_sensor_temp(0x49).unwrap(), 72);
    }

    #[test]
    fn test_full_diag() {
        let ctrl = make_test_controller();
        let report = ctrl.run_full_diag().unwrap();
        assert_eq!(report.flash_id, 0xEF401800);
        assert_eq!(report.cpu_temp_c, 45);
        assert_eq!(report.gpu_temp_c, 72);
        assert!(report.bmc_version.contains("2.4.1"));
    }
}
}

Добавление новой шины позже

Когда понадобится четвёртая шина, меняются только две вещи: BoardConfig и DiagController. Зависимые сигнатуры не меняются. Число обобщённых параметров остаётся равным одному:

#![allow(unused)]
fn main() {
trait SmBus {
    fn smbus_read_byte(&self, addr: u8, cmd: u8) -> Result<u8, BusError>;
}

// 1. Добавляем один ассоциированный тип:
trait BoardConfig {
    type Spi: SpiBus;
    type Com: ComPort;
    type I3c: I3cBus;
    type Smb: SmBus;     // ← новое
}

// 2. Добавляем одно поле:
struct DiagController<Cfg: BoardConfig> {
    spi: Cfg::Spi,
    com: Cfg::Com,
    i3c: Cfg::I3c,
    smb: Cfg::Smb,       // ← новое
}

// 3. Указываем конкретный тип в каждой реализации конфигурации:
impl BoardConfig for ProductionBoard {
    type Spi = PlatformSpi;
    type Com = UartCom;
    type I3c = LinuxI3c;
    type Smb = LinuxSmbus; // ← новое
}
}

Когда использовать этот паттерн

СитуацияИспользовать конфигурационный трейт?Альтернатива
У структуры 3 и более обобщённых параметров с ограничениями трейтами✅ Да—
Нужно заменить весь аппаратный слой или платформу целиком✅ Да—
Только 1–2 обобщённых параметра❌ ИзбыточноПрямые обобщённые параметры
Нужен полиморфизм во время выполнения❌Трейт-объекты dyn Trait
Открытая система плагинов❌Карта типов / Any
Трейты компонентов естественно образуют группу (плата, платформа)✅ Да—

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

  • Один обобщённый параметр навсегда: DiagController<Cfg> никогда не получит новых <A, B, C, ...>
  • Полностью статическая диспетчеризация: никаких vtable, dyn и выделений памяти в куче для трейт-объектов
  • Чистая подмена в тестах: определите TestBoard с моками, без условной компиляции
  • Безопасность на этапе компиляции: забыли ассоциированный тип → ошибка компиляции, а не падение во время выполнения
  • Проверено на практике: именно этот паттерн использует система frame в Substrate/Polkadot, чтобы управлять более чем 20 ассоциированными типами через один трейт Config

Ключевые выводы: newtype и type-state

  • Newtype даёт безопасность типов на этапе компиляции без затрат во время выполнения
  • Type-state превращает недопустимые переходы состояний в ошибку компиляции, а не в ошибку времени выполнения
  • Конфигурационные трейты укрощают разрастание обобщённых параметров в больших системах

См. также: гл. 4 — PhantomData — нулевые маркеры, на которых держится type-state. гл. 2 — Трейты в деталях — ассоциированные типы в паттерне конфигурационного трейта.


Пример: двухосевой typestate (поставщик × состояние протокола)

Описанные выше паттерны работают по одной оси за раз: typestate обеспечивает порядок протокола, а абстракция через трейты справляется с несколькими поставщиками. Реальным системам часто нужны обе вещи одновременно: обёртка Handle<Vendor, State>, в которой доступные методы зависят от того, какой поставщик подключён, и от того, в каком состоянии находится дескриптор.

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

Двумерная задача

Рассмотрим интерфейс отладочного зонда (JTAG/SWD). Зонды выпускают разные поставщики, и каждый зонд нужно разблокировать, прежде чем станут доступны регистры. Некоторые поставщики дополнительно поддерживают прямое чтение памяти, но только после расширенной разблокировки, которая настраивает порт доступа к памяти:

graph LR
    subgraph "Все поставщики"
        L["🔒 Заблокирован"] -- "unlock()" --> U["🔓 Разблокирован"]
    end
    subgraph "Только поставщики с поддержкой памяти"
        U -- "extended_unlock()" --> E["🔓🧠 Расширенно разблокирован"]
    end

    U -. "read_reg() / write_reg()" .-> U
    E -. "read_reg() / write_reg()" .-> E
    E -. "read_memory() / write_memory()" .-> E

    style L fill:#fee,stroke:#c33
    style U fill:#efe,stroke:#3a3
    style E fill:#eef,stroke:#33c

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

block-beta
    columns 4
    space header1["Заблокирован"] header2["Разблокирован"] header3["Расширенно разблокирован"]
    basic["Базовый поставщик"]:1 b1["unlock()"] b2["read_reg()<br/>write_reg()"] b3["— недостижимо —"]
    memory["Поставщик с памятью"]:1 m1["unlock()"] m2["read_reg()<br/>write_reg()<br/>extended_unlock()"] m3["read_reg()<br/>write_reg()<br/>read_memory()<br/>write_memory()"]

    style b1 fill:#ffd,stroke:#aa0
    style b2 fill:#efe,stroke:#3a3
    style b3 fill:#eee,stroke:#999,stroke-dasharray: 5 5
    style m1 fill:#ffd,stroke:#aa0
    style m2 fill:#efe,stroke:#3a3
    style m3 fill:#eef,stroke:#33c

Задача: выразить эту матрицу полностью на этапе компиляции со статической диспетчеризацией, чтобы вызов extended_unlock() у базового зонда или read_memory() у разблокированного, но не расширенно разблокированного дескриптора был ошибкой компиляции.

Решение: Jtag<V, S> с маркерными трейтами

Шаг 1: токены состояний и маркеры возможностей:

use std::marker::PhantomData;

// Токены состояний нулевого размера: без затрат во время выполнения
struct Locked;
struct Unlocked;
struct ExtendedUnlocked;

// Маркерные трейты описывают, какие возможности есть у каждого состояния
trait HasRegAccess {}
impl HasRegAccess for Unlocked {}
impl HasRegAccess for ExtendedUnlocked {}

trait HasMemAccess {}
impl HasMemAccess for ExtendedUnlocked {}

Почему маркерные трейты, а не просто конкретные состояния? Запись impl<V, S: HasRegAccess> Jtag<V, S> означает, что read_reg() работает в любом состоянии с доступом к регистрам. Сегодня это Unlocked и ExtendedUnlocked, но если завтра добавить DebugHalted, достаточно одной строки: impl HasRegAccess for DebugHalted {}. Каждая функция для регистров станет работать с ним автоматически, без изменений в коде.

Шаг 2: трейты поставщиков (низкоуровневые операции):

// Каждый поставщик зондов реализует эти методы
trait JtagVendor {
    fn raw_unlock(&mut self);
    fn raw_read_reg(&self, addr: u32) -> u32;
    fn raw_write_reg(&mut self, addr: u32, val: u32);
}

// Поставщики с доступом к памяти дополнительно реализуют этот супертрейт
trait JtagMemoryVendor: JtagVendor {
    fn raw_extended_unlock(&mut self);
    fn raw_read_memory(&self, addr: u64, buf: &mut [u8]);
    fn raw_write_memory(&mut self, addr: u64, data: &[u8]);
}

Шаг 3: обёртка с условными блоками impl:

struct Jtag<V, S = Locked> {
    vendor: V,
    _state: PhantomData<S>,
}

// Конструирование: всегда начинается в состоянии Locked
impl<V: JtagVendor> Jtag<V, Locked> {
    fn new(vendor: V) -> Self {
        Jtag { vendor, _state: PhantomData }
    }

    fn unlock(mut self) -> Jtag<V, Unlocked> {
        self.vendor.raw_unlock();
        Jtag { vendor: self.vendor, _state: PhantomData }
    }
}

// Ввод-вывод регистров: любой поставщик, любое состояние с HasRegAccess
impl<V: JtagVendor, S: HasRegAccess> Jtag<V, S> {
    fn read_reg(&self, addr: u32) -> u32 {
        self.vendor.raw_read_reg(addr)
    }
    fn write_reg(&mut self, addr: u32, val: u32) {
        self.vendor.raw_write_reg(addr, val);
    }
}

// Расширенная разблокировка: только поставщики с памятью, только из Unlocked
impl<V: JtagMemoryVendor> Jtag<V, Unlocked> {
    fn extended_unlock(mut self) -> Jtag<V, ExtendedUnlocked> {
        self.vendor.raw_extended_unlock();
        Jtag { vendor: self.vendor, _state: PhantomData }
    }
}

// Ввод-вывод памяти: только поставщики с памятью, только в состоянии ExtendedUnlocked
impl<V: JtagMemoryVendor, S: HasMemAccess> Jtag<V, S> {
    fn read_memory(&self, addr: u64, buf: &mut [u8]) {
        self.vendor.raw_read_memory(addr, buf);
    }
    fn write_memory(&mut self, addr: u64, data: &[u8]) {
        self.vendor.raw_write_memory(addr, data);
    }
}

Каждый блок impl кодирует одну ячейку (или строку) матрицы возможностей. Компилятор обеспечивает соблюдение матрицы: никаких проверок во время выполнения.

Реализации поставщиков

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

// Поставщик A: базовый зонд, только доступ к регистрам
struct BasicProbe { port: u16 }

impl JtagVendor for BasicProbe {
    fn raw_unlock(&mut self)                    { /* последовательность TAP reset */ }
    fn raw_read_reg(&self, addr: u32) -> u32    { /* DR scan */  0 }
    fn raw_write_reg(&mut self, addr: u32, val: u32) { /* DR scan */ }
}
// BasicProbe НЕ реализует JtagMemoryVendor.
// extended_unlock() не скомпилируется для Jtag<BasicProbe, _>.

// Поставщик B: полнофункциональный зонд, регистры и память
struct DapProbe { serial: String }

impl JtagVendor for DapProbe {
    fn raw_unlock(&mut self)                    { /* переключение на SWD, чтение DPIDR */ }
    fn raw_read_reg(&self, addr: u32) -> u32    { /* чтение регистра AP */ 0 }
    fn raw_write_reg(&mut self, addr: u32, val: u32) { /* запись регистра AP */ }
}

impl JtagMemoryVendor for DapProbe {
    fn raw_extended_unlock(&mut self)           { /* выбор MEM-AP, включение питания */ }
    fn raw_read_memory(&self, addr: u64, buf: &mut [u8])  { /* чтение MEM-AP */ }
    fn raw_write_memory(&mut self, addr: u64, data: &[u8]) { /* запись MEM-AP */ }
}

Что предотвращает компилятор

ПопыткаОшибкаПричина
Jtag<_, Locked>::read_reg()no method read_regLocked не реализует HasRegAccess
Jtag<BasicProbe, _>::extended_unlock()no method extended_unlockBasicProbe не реализует JtagMemoryVendor
Jtag<_, Unlocked>::read_memory()no method read_memoryUnlocked не реализует HasMemAccess
Двойной вызов unlock()value used after moveunlock() поглощает self

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

Написание обобщённых функций

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

/// Работает с ЛЮБЫМ поставщиком и ЛЮБЫМ состоянием, которое даёт доступ к регистрам.
fn read_idcode<V: JtagVendor, S: HasRegAccess>(jtag: &Jtag<V, S>) -> u32 {
    jtag.read_reg(0x00)
}

/// Компилируется только для поставщиков с памятью в состоянии ExtendedUnlocked.
fn dump_firmware<V: JtagMemoryVendor, S: HasMemAccess>(jtag: &Jtag<V, S>) {
    let mut buf = [0u8; 256];
    jtag.read_memory(0x0800_0000, &mut buf);
}

read_idcode не важно, находитесь ли вы в Unlocked или ExtendedUnlocked: ему нужен только HasRegAccess. Здесь маркерные трейты окупаются по сравнению с жёсткой фиксацией конкретных состояний в сигнатурах.

Тот же паттерн в другой предметной области: бэкенды хранилища

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

// Состояния
struct Closed;
struct Open;
struct InTransaction;

trait HasReadWrite {}
impl HasReadWrite for Open {}
impl HasReadWrite for InTransaction {}

// Трейты поставщиков
trait StorageBackend {
    fn raw_open(&mut self);
    fn raw_read(&self, key: &[u8]) -> Option<Vec<u8>>;
    fn raw_write(&mut self, key: &[u8], value: &[u8]);
}

trait TransactionalBackend: StorageBackend {
    fn raw_begin(&mut self);
    fn raw_commit(&mut self);
    fn raw_rollback(&mut self);
}

// Обёртка
struct Store<B, S = Closed> { backend: B, _s: PhantomData<S> }

impl<B: StorageBackend> Store<B, Closed> {
    fn open(mut self) -> Store<B, Open> { self.backend.raw_open(); /* ... */ todo!() }
}
impl<B: StorageBackend, S: HasReadWrite> Store<B, S> {
    fn read(&self, key: &[u8]) -> Option<Vec<u8>>  { self.backend.raw_read(key) }
    fn write(&mut self, key: &[u8], val: &[u8])    { self.backend.raw_write(key, val) }
}
impl<B: TransactionalBackend> Store<B, Open> {
    fn begin(mut self) -> Store<B, InTransaction>   { /* ... */ todo!() }
}
impl<B: TransactionalBackend> Store<B, InTransaction> {
    fn commit(mut self) -> Store<B, Open>           { /* ... */ todo!() }
    fn rollback(mut self) -> Store<B, Open>         { /* ... */ todo!() }
}

Бэкенд на плоских файлах реализует только StorageBackend, поэтому begin() не скомпилируется. Бэкенд базы данных добавляет TransactionalBackend, и становится доступен полный цикл Open → InTransaction → Open.

Когда прибегать к этому паттерну

ПризнакПочему подходит двухосевой паттерн
Две независимые оси: «кто предоставляет» и «в каком состоянии»Матрица блоков impl напрямую кодирует обе оси
У одних поставщиков строго больше возможностей, чем у другихСупертрейт (MemoryVendor: Vendor) и условный impl
Неправильное использование состояния или возможности — ошибка безопасности или корректностиПредотвращение на этапе компиляции важнее проверок во время выполнения
Нужна статическая диспетчеризация (без vtable)PhantomData + обобщения = без накладных расходов
ПризнакЧто рассмотреть проще
Меняется только одна ось (состояние ИЛИ поставщик, но не обе)Одноосевой typestate или обычные трейт-объекты
Три и более независимых осейКонфигурационный трейт (выше) объединяет оси в ассоциированные типы
Допустим полиморфизм во время выполненияСостояние в виде enum и диспетчеризация через dyn проще

Когда осей становится три и больше: Если вы обнаруживаете, что пишете Handle<V, S, D, T> (поставщик, состояние, уровень отладки, транспорт), то список обобщённых параметров говорит сам за себя. Подумайте о том, чтобы свернуть ось поставщика в конфигурационный трейт с ассоциированными типами (см. Паттерн конфигурационного трейта выше в этой главе), оставив ось состояния обобщённым параметром: Handle<Cfg, S>. Конфигурационный трейт объединяет type Vendor, type Transport и т. д. в один параметр, а ось состояния сохраняет гарантии переходов на этапе компиляции. Это естественная эволюция, а не переписывание: вы переносите типы, связанные с поставщиком, в Cfg, а механизм typestate не трогаете.

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


Упражнение: типобезопасный автомат состояний ★★ (~30 минут)

Постройте автомат состояний светофора с помощью паттерна type-state. Свет должен переходить Red → Green → Yellow → Red, и никакой другой порядок не должен быть возможен.

🔑 Решение
use std::marker::PhantomData;

struct Red;
struct Green;
struct Yellow;

struct TrafficLight<State> {
    _state: PhantomData<State>,
}

impl TrafficLight<Red> {
    fn new() -> Self {
        println!("🔴 Красный — СТОП");
        TrafficLight { _state: PhantomData }
    }

    fn go(self) -> TrafficLight<Green> {
        println!("🟢 Зелёный — ВПЕРЁД");
        TrafficLight { _state: PhantomData }
    }
}

impl TrafficLight<Green> {
    fn caution(self) -> TrafficLight<Yellow> {
        println!("🟡 Жёлтый — ВНИМАНИЕ");
        TrafficLight { _state: PhantomData }
    }
}

impl TrafficLight<Yellow> {
    fn stop(self) -> TrafficLight<Red> {
        println!("🔴 Красный — СТОП");
        TrafficLight { _state: PhantomData }
    }
}

fn main() {
    let light = TrafficLight::new(); // Красный
    let light = light.go();          // Зелёный
    let light = light.caution();     // Жёлтый
    let _light = light.stop();       // Красный

    // light.caution(); // ❌ Compile error: no method `caution` on Red
    // TrafficLight::new().stop(); // ❌ Compile error: no method `stop` on Red
}

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


4. PhantomData — типы, которые не несут данных 🔴

Что вы узнаете:

  • Зачем нужен PhantomData<T> и три проблемы, которые он решает
  • Маркировка времён жизни (lifetime branding) для проверки области действия на этапе компиляции
  • Паттерн «единицы измерения» для арифметики с проверкой размерностей
  • Вариантность (ковариантность, контравариантность, инвариантность) и то, как ею управляет PhantomData

Что решает PhantomData

PhantomData<T> — это тип нулевого размера, который сообщает компилятору: «эта структура логически связана с T, хотя самого T она не содержит». Он влияет на вариантность, проверку drop и автоматический вывод auto-трейтов, не занимая при этом памяти.

#![allow(unused)]
fn main() {
use std::marker::PhantomData;

// Без PhantomData:
struct Slice<'a, T> {
    ptr: *const T,
    len: usize,
    // Проблема: компилятор не знает, что эта структура заимствует данные из 'a,
    // и не учитывает связь с T при проверке drop
}

// С PhantomData:
struct Slice<'a, T> {
    ptr: *const T,
    len: usize,
    _marker: PhantomData<&'a T>,
    // Теперь компилятор знает:
    // 1. Эта структура заимствует данные с временем жизни 'a
    // 2. Она ковариантна по 'a (времена жизни могут сокращаться)
    // 3. Проверка drop учитывает T
}
}

Три задачи PhantomData:

ЗадачаПримерЧто делает
Привязка времени жизниPhantomData<&'a T>Структура считается заимствующей 'a
Имитация владенияPhantomData<T>Проверка drop считает, что структура владеет T
Управление вариантностьюPhantomData<fn(T)>Делает структуру контравариантной по T

Маркировка времени жизни (lifetime branding)

Используйте PhantomData, чтобы не допустить смешивания значений из разных «сессий» или «контекстов»:

use std::cell::RefCell;
use std::marker::PhantomData;

/// Дескриптор, привязанный к конкретному экземпляру арены.
/// Инвариантен по 'arena — не позволяет использовать дескриптор одной арены с другой.
struct ArenaHandle<'arena> {
    index: usize,
    _brand: PhantomData<*mut &'arena ()>,
}

/// Арена, которая помечает каждый дескриптор своим уникальным временем жизни.
struct Arena<'arena> {
    data: RefCell<Vec<String>>,
    _phantom: PhantomData<&'arena ()>,
}

/// Создаёт арену и передаёт её в замыкание.
/// Каждый вызов получает уникальное непрозрачное время жизни, которое нельзя подделать.
fn with_arena<R>(f: impl for<'arena> FnOnce(&Arena<'arena>) -> R) -> R {
    let arena = Arena {
        data: RefCell::new(Vec::new()),
        _phantom: PhantomData,
    };
    f(&arena)
}

impl<'arena> Arena<'arena> {
    /// Выделяет строку и возвращает дескриптор с маркером арены
    fn alloc(&self, value: String) -> ArenaHandle<'arena> {
        let mut data = self.data.borrow_mut();
        let index = data.len();
        data.push(value);
        ArenaHandle { index, _brand: PhantomData }
    }

    /// Поиск по дескриптору: принимает только дескрипторы ЭТОЙ арены
    fn get(&self, handle: &ArenaHandle<'arena>) -> String {
        let data = self.data.borrow();
        data[handle.index].clone()
    }
}

fn main() {
    with_arena(|arena1| {
        let handle1 = arena1.alloc("hello".to_string());
        println!("{}", arena1.get(&handle1)); // ✅

        // Нельзя использовать handle1 с другой ареной — ошибка на этапе компиляции
        // with_arena(|arena2| {
        //     arena2.get(&handle1); // ❌ заимствованные данные выходят за пределы замыкания
        // });
    });
}

Паттерн «единицы измерения»

Предотвращайте смешивание несовместимых единиц на этапе компиляции без затрат во время выполнения:

use std::marker::PhantomData;
use std::ops::{Add, Mul};

// Маркерные типы единиц (нулевого размера)
struct Meters;
struct Seconds;
struct MetersPerSecond;

#[derive(Debug, Clone, Copy)]
struct Quantity<Unit> {
    value: f64,
    _unit: PhantomData<Unit>,
}

impl<U> Quantity<U> {
    fn new(value: f64) -> Self {
        Quantity { value, _unit: PhantomData }
    }
}

// Складывать можно только одинаковые единицы:
impl<U> Add for Quantity<U> {
    type Output = Quantity<U>;
    fn add(self, rhs: Self) -> Self::Output {
        Quantity::new(self.value + rhs.value)
    }
}

// Метры / секунды = метры в секунду (собственная реализация трейта)
impl std::ops::Div<Quantity<Seconds>> for Quantity<Meters> {
    type Output = Quantity<MetersPerSecond>;
    fn div(self, rhs: Quantity<Seconds>) -> Quantity<MetersPerSecond> {
        Quantity::new(self.value / rhs.value)
    }
}

fn main() {
    let dist = Quantity::<Meters>::new(100.0);
    let time = Quantity::<Seconds>::new(9.58);
    let speed = dist / time; // Quantity<MetersPerSecond>
    println!("Скорость: {:.2} м/с", speed.value); // 10.44 м/с

    // let nonsense = dist + time; // ❌ Compile error: can't add Meters + Seconds
}

Это чистая магия системы типов: PhantomData<Meters> имеет нулевой размер, поэтому Quantity<Meters> имеет ту же раскладку в памяти, что и f64. Во время выполнения никаких накладных расходов на обёртку, но полная проверка единиц на этапе компиляции.

PhantomData и проверка drop

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

#![allow(unused)]
fn main() {
use std::marker::PhantomData;

// PhantomData<T>: компилятор считает, что мы МОЖЕМ удалить T
// Это значит, что T должен жить дольше нашей структуры
struct OwningSemantic<T> {
    ptr: *const T,
    _marker: PhantomData<T>,  // «Логически я владею T»
}

// PhantomData<*const T>: компилятор считает, что мы НЕ владеем T
// Более либерально: T не обязан жить дольше нас
struct NonOwningSemantic<T> {
    ptr: *const T,
    _marker: PhantomData<*const T>,  // «Я лишь указываю на T»
}
}

Практическое правило: при обёртывании сырых указателей выбирайте PhantomData внимательно:

  • Пишете контейнер, который владеет своими данными? → PhantomData<T>
  • Пишете тип-представление (view) или ссылку? → PhantomData<&'a T> или PhantomData<*const T>

Вариантность: почему важен параметр типа в PhantomData

Вариантность определяет, можно ли подставить вместо обобщённого типа его подтип или надтип (в Rust «подтип» означает «время жизни длиннее»). Ошибка в вариантности приводит либо к отклонению корректного кода, либо к принятию небезопасного.

graph LR
    subgraph Ковариантность
        direction TB
        A1["&'long T"] -->|"может стать"| A2["&'short T"]
    end

    subgraph Контравариантность
        direction TB
        B1["fn(&'short T)"] -->|"может стать"| B2["fn(&'long T)"]
    end

    subgraph Инвариантность
        direction TB
        C1["&'a mut T"] ---|"подстановка НЕ допускается"| C2["&'b mut T"]
    end

    style A1 fill:#d4efdf,stroke:#27ae60,color:#000
    style A2 fill:#d4efdf,stroke:#27ae60,color:#000
    style B1 fill:#e8daef,stroke:#8e44ad,color:#000
    style B2 fill:#e8daef,stroke:#8e44ad,color:#000
    style C1 fill:#fadbd8,stroke:#e74c3c,color:#000
    style C2 fill:#fadbd8,stroke:#e74c3c,color:#000

Три вариантности

ВариантностьСмысл«Можно ли подставить…»Пример в Rust
КовариантностьПодтип передаётся по направлению'long там, где ожидается 'short ✅&'a T, Vec<T>, Box<T>
КонтравариантностьПодтип передаётся против направления'short там, где ожидается 'long ✅fn(T) (в позиции параметра)
ИнвариантностьПодстановка не допускаетсяНи в одну сторону ✅&mut T, Cell<T>, UnsafeCell<T>

Почему &'a T ковариантна по 'a

fn print_str(s: &str) {
    println!("{s}");
}

fn main() {
    let owned = String::from("hello");
    // owned живёт всё время выполнения функции ('long)
    // print_str ожидает &'_ str ('short: только на время вызова)
    print_str(&owned); // ✅ Ковариантность: 'long → 'short безопасно
    // Ссылка с более долгим временем жизни всегда может использоваться там, где нужна более короткая.
}

Почему &mut T инвариантна по T

#![allow(unused)]
fn main() {
// Если бы &mut T была ковариантна по T, этот код скомпилировался бы:
fn evil(s: &mut &'static str) {
    // Мы могли бы записать в слот &'static str ссылку с более коротким временем жизни!
    let local = String::from("temporary");
    // *s = &local; // ← Создал бы висячий &'static str
}

// Инвариантность запрещает это: &'static str ≠ &'a str при мутации.
// Компилятор полностью отклоняет подстановку.
}

Как PhantomData управляет вариантностью

PhantomData<X> придаёт вашей структуре ту же вариантность, что и у X:

#![allow(unused)]
fn main() {
use std::marker::PhantomData;

// Ковариантна по 'a: Ref<'long> можно использовать как Ref<'short>
struct Ref<'a, T> {
    ptr: *const T,
    _marker: PhantomData<&'a T>,  // Ковариантна по 'a, ковариантна по T
}

// Инвариантна по T: предотвращает небезопасное сокращение времени жизни T
struct MutRef<'a, T> {
    ptr: *mut T,
    _marker: PhantomData<&'a mut T>,  // Ковариантна по 'a, ИНВАРИАНТНА по T
}

// Контравариантна по T: полезна для контейнеров callback-функций
struct CallbackSlot<T> {
    _marker: PhantomData<fn(T)>,  // Контравариантна по T
}
}

Шпаргалка по вариантности PhantomData:

Тип PhantomDataВариантность по TВариантность по 'aКогда использовать
PhantomData<T>Ковариантна—Вы логически владеете T
PhantomData<&'a T>КовариантнаКовариантнаВы заимствуете T со временем жизни 'a
PhantomData<&'a mut T>ИнвариантнаКовариантнаВы заимствуете T изменяемо
PhantomData<*const T>Ковариантна—Невладеющий указатель на T
PhantomData<*mut T>Инвариантна—Невладеющий изменяемый указатель
PhantomData<fn(T)>Контравариантна—T находится в позиции аргумента
PhantomData<fn() -> T>Ковариантна—T находится в позиции возвращаемого значения
PhantomData<fn(T) -> T>Инвариантна—T в обеих позициях: ковариантность и контравариантность уравновешивают друг друга

Пример: почему это важно на практике

use std::marker::PhantomData;

// Токен, который помечает значения временем жизни сессии.
// ДОЛЖЕН быть ковариантен по 'a, иначе вызывающий код не сможет сократить
// время жизни при передаче в функции, которым нужно более короткое заимствование.
struct SessionToken<'a> {
    id: u64,
    _brand: PhantomData<&'a ()>,  // ✅ Ковариантна: вызывающий код может сократить 'a
    // _brand: PhantomData<fn(&'a ())>,  // ❌ Контравариантна: ломает эргономику
    // _brand: PhantomData<&'a mut ()>;  // По-прежнему ковариантна по 'a (инвариантна по T, но T фиксирован как ())
}

fn use_token(token: &SessionToken<'_>) {
    println!("Используем токен {}", token.id);
}

fn main() {
    let token = SessionToken { id: 42, _brand: PhantomData };
    use_token(&token); // ✅ Работает, потому что SessionToken ковариантна по 'a
}

Правило выбора: начинайте с PhantomData<&'a T> (ковариантна). Переходите к PhantomData<&'a mut T> (инвариантна), только если ваша абстракция выдаёт изменяемый доступ к T. PhantomData<fn(T)> (контравариантна) почти никогда не нужна: она корректна только в сценариях хранения callback-функций.

Ключевые выводы: PhantomData

  • PhantomData<T> несёт информацию о типе и времени жизни без затрат во время выполнения
  • Используйте его для маркировки времён жизни, управления вариантностью и паттерна единиц измерения
  • Проверка drop: PhantomData<T> сообщает компилятору, что ваш тип логически владеет T

См. также: гл. 3 — Newtype и type-state — паттерны type-state, которые используют PhantomData. гл. 12 — Unsafe Rust — о том, как PhantomData взаимодействует с сырыми указателями.


Упражнение: единицы измерения с PhantomData ★★ (~30 минут)

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

  • Meters, Seconds, Kilograms
  • сложение одинаковых единиц
  • умножение: Meters * Meters = SquareMeters
  • деление: Meters / Seconds = MetersPerSecond
🔑 Решение
use std::marker::PhantomData;
use std::ops::{Add, Mul, Div};

#[derive(Clone, Copy)]
struct Meters;
#[derive(Clone, Copy)]
struct Seconds;
#[derive(Clone, Copy)]
struct Kilograms;
#[derive(Clone, Copy)]
struct SquareMeters;
#[derive(Clone, Copy)]
struct MetersPerSecond;

#[derive(Debug, Clone, Copy)]
struct Qty<U> {
    value: f64,
    _unit: PhantomData<U>,
}

impl<U> Qty<U> {
    fn new(v: f64) -> Self { Qty { value: v, _unit: PhantomData } }
}

impl<U> Add for Qty<U> {
    type Output = Qty<U>;
    fn add(self, rhs: Self) -> Self::Output { Qty::new(self.value + rhs.value) }
}

impl Mul<Qty<Meters>> for Qty<Meters> {
    type Output = Qty<SquareMeters>;
    fn mul(self, rhs: Qty<Meters>) -> Qty<SquareMeters> {
        Qty::new(self.value * rhs.value)
    }
}

impl Div<Qty<Seconds>> for Qty<Meters> {
    type Output = Qty<MetersPerSecond>;
    fn div(self, rhs: Qty<Seconds>) -> Qty<MetersPerSecond> {
        Qty::new(self.value / rhs.value)
    }
}

fn main() {
    let width = Qty::<Meters>::new(5.0);
    let height = Qty::<Meters>::new(3.0);
    let area = width * height; // Qty<SquareMeters>
    println!("Площадь: {:.1} м²", area.value);

    let dist = Qty::<Meters>::new(100.0);
    let time = Qty::<Seconds>::new(9.58);
    let speed = dist / time;
    println!("Скорость: {:.2} м/с", speed.value);

    let sum = width + height; // Одинаковые единицы ✅
    println!("Сумма: {:.1} м", sum.value);

    // let bad = width + time; // ❌ Compile error: can't add Meters + Seconds
}

5. Каналы и передача сообщений 🟢

Что вы узнаете:

  • Основы std::sync::mpsc и когда стоит перейти на crossbeam-channel
  • Выбор канала через select! для обработки сообщений из нескольких источников
  • Ограниченные и неограниченные каналы и стратегии обратного давления (backpressure)
  • Паттерн актора для инкапсуляции конкурентного состояния

std::sync::mpsc: стандартный канал

Стандартная библиотека Rust предоставляет канал с несколькими отправителями и одним получателем (multi-producer, single-consumer):

use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    // Создаём канал: tx (отправитель) и rx (получатель)
    let (tx, rx) = mpsc::channel();

    // Запускаем поток-производитель
    let tx1 = tx.clone(); // Клонируем для нескольких производителей
    thread::spawn(move || {
        for i in 0..5 {
            tx1.send(format!("производитель-1: сообщение {i}")).unwrap();
            thread::sleep(Duration::from_millis(100));
        }
    });

    // Второй производитель
    thread::spawn(move || {
        for i in 0..5 {
            tx.send(format!("производитель-2: сообщение {i}")).unwrap();
            thread::sleep(Duration::from_millis(150));
        }
    });

    // Потребитель: получает все сообщения
    for msg in rx {
        // Итератор rx завершается, когда уничтожены ВСЕ отправители
        println!("Получено: {msg}");
    }
    println!("Все производители завершили работу.");
}

Примечание: .unwrap() при .send() используется для краткости. Он паникует, если получатель уже уничтожен. Продакшен-код должен корректно обрабатывать SendError.

Ключевые свойства:

  • Неограниченный по умолчанию: может заполнить память, если потребитель медленный
  • mpsc::sync_channel(N) создаёт ограниченный канал с обратным давлением
  • rx.recv() блокирует текущий поток, пока не придёт сообщение
  • rx.try_recv() сразу возвращает Err(TryRecvError::Empty), если ничего не готово
  • Канал закрывается, когда уничтожены все Sender
#![allow(unused)]
fn main() {
// Ограниченный канал с обратным давлением:
let (tx, rx) = mpsc::sync_channel(10); // Буфер на 10 сообщений

thread::spawn(move || {
    for i in 0..1000 {
        tx.send(i).unwrap(); // БЛОКИРУЕТСЯ, если буфер полон: естественное обратное давление
    }
});
}

Примечание: .unwrap() используется для краткости. В продакшене обрабатывайте SendError (получатель уничтожен), а не паникуйте.

crossbeam-channel: рабочая лошадка для продакшена

crossbeam-channel — де-факто стандарт для каналов в продакшене. Он быстрее std::sync::mpsc и поддерживает нескольких получателей (mpmc):

// Cargo.toml:
//   [dependencies]
//   crossbeam-channel = "0.5"
use crossbeam_channel::{bounded, unbounded, select, Sender, Receiver};
use std::thread;
use std::time::Duration;

fn main() {
    // Ограниченный канал MPMC
    let (tx, rx) = bounded::<String>(100);

    // Несколько производителей
    for id in 0..4 {
        let tx = tx.clone();
        thread::spawn(move || {
            for i in 0..10 {
                tx.send(format!("рабочий-{id}: элемент-{i}")).unwrap();
            }
        });
    }
    drop(tx); // Отбрасываем исходный отправитель, чтобы канал мог закрыться

    // Несколько потребителей (с std::sync::mpsc так нельзя!)
    let rx2 = rx.clone();
    let consumer1 = thread::spawn(move || {
        while let Ok(msg) = rx.recv() {
            println!("[потребитель-1] {msg}");
        }
    });
    let consumer2 = thread::spawn(move || {
        while let Ok(msg) = rx2.recv() {
            println!("[потребитель-2] {msg}");
        }
    });

    consumer1.join().unwrap();
    consumer2.join().unwrap();
}

Выбор канала (select!)

Слушайте несколько каналов одновременно, как select в Go:

use crossbeam_channel::{bounded, tick, after, select};
use std::time::Duration;

fn main() {
    let (work_tx, work_rx) = bounded::<String>(10);
    let ticker = tick(Duration::from_secs(1));        // Периодический тик
    let deadline = after(Duration::from_secs(10));     // Одноразовый таймаут

    // Производитель
    let tx = work_tx.clone();
    std::thread::spawn(move || {
        for i in 0..100 {
            tx.send(format!("задача-{i}")).unwrap();
            std::thread::sleep(Duration::from_millis(500));
        }
    });
    drop(work_tx);

    loop {
        select! {
            recv(work_rx) -> msg => {
                match msg {
                    Ok(job) => println!("Обработка: {job}"),
                    Err(_) => {
                        println!("Канал задач закрыт");
                        break;
                    }
                }
            },
            recv(ticker) -> _ => {
                println!("Тик — сигнал жизни");
            },
            recv(deadline) -> _ => {
                println!("Достигнут дедлайн — завершение работы");
                break;
            },
        }
    }
}

Сравнение с Go: это в точности похоже на оператор select для каналов в Go. Макрос select! из crossbeam, как и в Go, случайным образом выбирает порядок веток, чтобы избежать голодания.

Ограниченные и неограниченные каналы и обратное давление

ТипПоведение при заполненииПамятьСценарий использования
НеограниченныйНикогда не блокирует (растёт в куче)Не ограничена ⚠️Редко: только когда производитель медленнее потребителя
Ограниченныйsend() блокируется, пока не появится местоФиксированаВыбор по умолчанию в продакшене: защита от OOM
Рандеву (bounded(0))send() блокируется, пока получатель не готовНетСинхронизация и передача управления
#![allow(unused)]
fn main() {
// Канал рандеву: нулевая ёмкость, прямая передача
let (tx, rx) = crossbeam_channel::bounded(0);
// tx.send(x) блокируется, пока не вызван rx.recv(), и наоборот.
// Это точно синхронизирует два потока.
}

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

Паттерн актора на каналах

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

use std::sync::mpsc;
use std::thread;

// Сообщения, которые может получить актор
enum CounterMsg {
    Increment,
    Decrement,
    Get(mpsc::Sender<i64>), // Канал для ответа
}

struct CounterActor {
    count: i64,
    rx: mpsc::Receiver<CounterMsg>,
}

impl CounterActor {
    fn new(rx: mpsc::Receiver<CounterMsg>) -> Self {
        CounterActor { count: 0, rx }
    }

    fn run(mut self) {
        while let Ok(msg) = self.rx.recv() {
            match msg {
                CounterMsg::Increment => self.count += 1,
                CounterMsg::Decrement => self.count -= 1,
                CounterMsg::Get(reply) => {
                    let _ = reply.send(self.count);
                }
            }
        }
    }
}

// Дескриптор актора: дёшево клонируется, реализует Send + Sync
#[derive(Clone)]
struct Counter {
    tx: mpsc::Sender<CounterMsg>,
}

impl Counter {
    fn spawn() -> Self {
        let (tx, rx) = mpsc::channel();
        thread::spawn(move || CounterActor::new(rx).run());
        Counter { tx }
    }

    fn increment(&self) { let _ = self.tx.send(CounterMsg::Increment); }
    fn decrement(&self) { let _ = self.tx.send(CounterMsg::Decrement); }

    fn get(&self) -> i64 {
        let (reply_tx, reply_rx) = mpsc::channel();
        self.tx.send(CounterMsg::Get(reply_tx)).unwrap();
        reply_rx.recv().unwrap()
    }
}

fn main() {
    let counter = Counter::spawn();

    // Несколько потоков могут безопасно использовать счётчик: никакого мьютекса!
    let handles: Vec<_> = (0..10).map(|_| {
        let counter = counter.clone();
        thread::spawn(move || {
            for _ in 0..1000 {
                counter.increment();
            }
        })
    }).collect();

    for h in handles { h.join().unwrap(); }
    println!("Итоговое значение: {}", counter.get()); // 10000
}

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

Ключевые выводы: каналы

  • crossbeam-channel — рабочая лошадка для продакшена: быстрее и богаче по возможностям, чем std::sync::mpsc
  • select! заменяет сложный опрос нескольких источников декларативным выбором канала
  • Ограниченные каналы дают естественное обратное давление; неограниченные каналы рискуют привести к OOM

См. также: гл. 6 — Конкурентность о потоках, Mutex и разделяемом состоянии. гл. 16 — Async о каналах в async (tokio::sync::mpsc).


Упражнение: пул воркеров на каналах ★★★ (~45 минут)

Постройте пул воркеров на каналах, где:

  • диспетчер отправляет структуры Job через канал
  • N воркеров забирают задачи и отправляют результаты обратно
  • используйте std::sync::mpsc с Arc<Mutex<Receiver>> для общей очереди работы
🔑 Решение
use std::sync::mpsc;
use std::thread;

struct Job {
    id: u64,
    data: String,
}

struct JobResult {
    job_id: u64,
    output: String,
    worker_id: usize,
}

fn worker_pool(jobs: Vec<Job>, num_workers: usize) -> Vec<JobResult> {
    let (job_tx, job_rx) = mpsc::channel::<Job>();
    let (result_tx, result_rx) = mpsc::channel::<JobResult>();

    let job_rx = std::sync::Arc::new(std::sync::Mutex::new(job_rx));

    let mut handles = Vec::new();
    for worker_id in 0..num_workers {
        let job_rx = job_rx.clone();
        let result_tx = result_tx.clone();
        handles.push(thread::spawn(move || {
            loop {
                let job = {
                    let rx = job_rx.lock().unwrap();
                    rx.recv()
                };
                match job {
                    Ok(job) => {
                        let output = format!("обработано '{}' воркером {worker_id}", job.data);
                        result_tx.send(JobResult {
                            job_id: job.id, output, worker_id,
                        }).unwrap();
                    }
                    Err(_) => break,
                }
            }
        }));
    }
    drop(result_tx);

    let num_jobs = jobs.len();
    for job in jobs {
        job_tx.send(job).unwrap();
    }
    drop(job_tx);

    let results: Vec<_> = result_rx.into_iter().collect();
    assert_eq!(results.len(), num_jobs);

    for h in handles { h.join().unwrap(); }
    results
}

fn main() {
    let jobs: Vec<Job> = (0..20).map(|i| Job {
        id: i, data: format!("задача-{i}"),
    }).collect();

    let results = worker_pool(jobs, 4);
    for r in &results {
        println!("[воркер {}] задача {}: {}", r.worker_id, r.job_id, r.output);
    }
}

6. Конкурентность, параллелизм и потоки 🟡

Что вы узнаете:

  • Точное различие между конкурентностью и параллелизмом
  • Потоки ОС, scoped-потоки и rayon для параллелизма по данным
  • Примитивы разделяемого состояния: Arc, Mutex, RwLock, атомарные типы, Condvar
  • Ленивая инициализация через OnceLock/LazyLock и паттерны без блокировок

Терминология: конкурентность ≠ параллелизм

Эти термины часто путают. Вот точное различие:

КонкурентностьПараллелизм
ОпределениеУправление несколькими задачами, которые могут продвигаться вперёдОдновременное выполнение нескольких задач
Требование к железуДостаточно одного ядраНужно несколько ядер
АналогияОдин повар, много блюд (переключается между ними)Несколько поваров, каждый готовит своё блюдо
Инструменты Rustasync/await, каналы, select!rayon, thread::spawn, par_iter()
Конкурентность (одно ядро):          Параллелизм (несколько ядер):

Задача A: ██░░██░░██                 Задача A: ██████████
Задача B: ░░██░░██░░                 Задача B: ██████████
─────────────────→ время             ─────────────────→ время
(чередуются на одном ядре)           (выполняются одновременно на двух ядрах)

std::thread: потоки ОС

Потоки Rust соответствуют потокам ОС один к одному. У каждого из них свой стек (обычно 2–8 МБ):

use std::thread;
use std::time::Duration;

fn main() {
    // Запускаем поток: принимает замыкание
    let handle = thread::spawn(|| {
        for i in 0..5 {
            println!("порождённый поток: {i}");
            thread::sleep(Duration::from_millis(100));
        }
        42 // Возвращаемое значение
    });

    // Одновременно работаем в главном потоке
    for i in 0..3 {
        println!("главный поток: {i}");
        thread::sleep(Duration::from_millis(150));
    }

    // Ждём завершения потока и получаем его возвращаемое значение
    let result = handle.join().unwrap(); // unwrap запаникует, если поток упал
    println!("Поток вернул: {result}");
}

Требования к типам в Thread::spawn:

#![allow(unused)]
fn main() {
// Замыкание должно быть:
// 1. Send: может быть передано в другой поток
// 2. 'static: не может заимствовать данные из вызывающей области видимости
// 3. FnOnce: забирает владение захваченными переменными

let data = vec![1, 2, 3];

// ❌ Заимствует data — не 'static
// thread::spawn(|| println!("{data:?}"));

// ✅ Передаём владение в поток
thread::spawn(move || println!("{data:?}"));
// data здесь больше недоступна
}

Scoped-потоки (std::thread::scope)

Начиная с Rust 1.63, scoped-потоки снимают требование 'static: потоки могут заимствовать данные из родительской области видимости:

use std::thread;

fn main() {
    let mut data = vec![1, 2, 3, 4, 5];

    thread::scope(|s| {
        // Поток 1: заимствуем разделяемую ссылку
        s.spawn(|| {
            let sum: i32 = data.iter().sum();
            println!("Сумма: {sum}");
        });

        // Поток 2: тоже заимствует разделяемую ссылку (несколько читателей — это нормально)
        s.spawn(|| {
            let max = data.iter().max().unwrap();
            println!("Максимум: {max}");
        });

        // ❌ Нельзя взять изменяемую ссылку, пока существуют разделяемые заимствования:
        // s.spawn(|| data.push(6));
    });
    // ВСЕ scoped-потоки завершаются здесь: гарантированно до возврата из scope

    // Теперь безопасно менять данные: все потоки завершены
    data.push(6);
    println!("Обновлено: {data:?}");
}

Это большое улучшение: до scoped-потоков всё, что нужно было разделить с потоками, приходилось клонировать через Arc::clone(). Теперь можно заимствовать данные напрямую, а компилятор доказывает, что все потоки завершатся до того, как данные выйдут из области видимости.

rayon: параллелизм по данным

rayon предоставляет параллельные итераторы, которые автоматически распределяют работу по пулу потоков:

// Cargo.toml: rayon = "1"
use rayon::prelude::*;

fn main() {
    let data: Vec<u64> = (0..1_000_000).collect();

    // Последовательно:
    let sum_seq: u64 = data.iter().map(|x| x * x).sum();

    // Параллельно: достаточно заменить .iter() на .par_iter()
    let sum_par: u64 = data.par_iter().map(|x| x * x).sum();

    assert_eq!(sum_seq, sum_par);

    // Параллельная сортировка:
    let mut numbers = vec![5, 2, 8, 1, 9, 3];
    numbers.par_sort();

    // Параллельная обработка через map/filter/collect:
    let results: Vec<_> = data
        .par_iter()
        .filter(|&&x| x % 2 == 0)
        .map(|&x| expensive_computation(x))
        .collect();
}

fn expensive_computation(x: u64) -> u64 {
    // Имитация вычислений, требующих много ресурсов CPU
    (0..1000).fold(x, |acc, _| acc.wrapping_mul(7).wrapping_add(13))
}

Когда использовать rayon, а когда потоки:

ИнструментКогда
rayon::par_iter()Параллельная обработка коллекций (map, filter, reduce)
thread::spawnДолгоживущие фоновые задачи, рабочие потоки для I/O
thread::scopeКороткоживущие параллельные задачи, которые заимствуют локальные данные
async + tokioКонкурентность, ограниченная I/O (сеть, файловый ввод-вывод)

Разделяемое состояние: Arc, Mutex, RwLock, атомарные типы

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

Примечание: .unwrap() при .lock(), .read() и .write() используется в этих примерах для краткости. Эти вызовы завершаются ошибкой только тогда, когда другой поток запаниковал, удерживая блокировку («отравление», poisoning). В продакшен-коде нужно решить, восстанавливаться ли после отравленной блокировки или передавать ошибку дальше.

#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex, RwLock};
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;

// --- Arc<Mutex<T>>: разделяемый и эксклюзивный доступ ---
fn mutex_example() {
    let counter = Arc::new(Mutex::new(0u64));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            for _ in 0..1000 {
                let mut guard = counter.lock().unwrap();
                *guard += 1;
            } // Защитник уничтожен → блокировка снята
        }));
    }

    for h in handles { h.join().unwrap(); }
    println!("Счётчик: {}", counter.lock().unwrap()); // 10000
}

// --- Arc<RwLock<T>>: несколько читателей ИЛИ один писатель ---
fn rwlock_example() {
    let config = Arc::new(RwLock::new(String::from("initial")));

    // Много читателей: не блокируют друг друга
    let readers: Vec<_> = (0..5).map(|id| {
        let config = Arc::clone(&config);
        thread::spawn(move || {
            let guard = config.read().unwrap();
            println!("Читатель {id}: {guard}");
        })
    }).collect();

    // Писатель: ждёт, пока завершатся все читатели
    {
        let mut guard = config.write().unwrap();
        *guard = "updated".to_string();
    }

    for r in readers { r.join().unwrap(); }
}

// --- Атомарные типы: без блокировок для простых значений ---
fn atomic_example() {
    let counter = Arc::new(AtomicU64::new(0));
    let mut handles = vec![];

    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 h in handles { h.join().unwrap(); }
    println!("Атомарный счётчик: {}", counter.load(Ordering::Relaxed)); // 10000
}
}

Краткое сравнение

ПримитивСценарийСтоимостьКонкуренция
Mutex<T>Короткие критические секцииЗахват и освобождениеПотоки стоят в очереди
RwLock<T>Много чтений, редкая записьБлокировка «читатель — писатель»Читатели работают одновременно, писатель эксклюзивен
AtomicU64 и др.Счётчики, флагиАппаратный CASБез блокировок, без ожидания
КаналыПередача сообщенийОперации с очередьюПроизводитель и потребитель разделены

Условные переменные (Condvar)

Condvar позволяет потоку ждать, пока другой поток не сообщит, что условие выполнено, без активного опроса (busy-loop). Он всегда используется вместе с Mutex:

#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex, Condvar};
use std::thread;

let pair = Arc::new((Mutex::new(false), Condvar::new()));
let pair2 = Arc::clone(&pair);

// Порождённый поток: ждём, пока ready == true
let handle = thread::spawn(move || {
    let (lock, cvar) = &*pair2;
    let mut ready = lock.lock().unwrap();
    while !*ready {
        ready = cvar.wait(ready).unwrap(); // атомарно снимает блокировку и засыпает
    }
    println!("Рабочий: условие выполнено, продолжаем");
});

// Главный поток: выставляем ready = true и сигнализируем
{
    let (lock, cvar) = &*pair;
    let mut ready = lock.lock().unwrap();
    *ready = true;
    cvar.notify_one(); // будим один ждущий поток (для многих используйте notify_all)
}
handle.join().unwrap();
}

Шаблон: после возврата из wait() всегда перепроверяйте условие в цикле while, потому что ОС допускает ложные пробуждения (spurious wakeups).

Ленивая инициализация: OnceLock и LazyLock

До Rust 1.80 для инициализации глобальной static-переменной, которой нужны вычисления во время выполнения (например, разбор конфигурации или компиляция регулярного выражения), требовался макрос lazy_static! или крейт once_cell. Теперь стандартная библиотека предоставляет два типа, которые покрывают эти сценарии без внешних зависимостей:

#![allow(unused)]
fn main() {
use std::sync::{OnceLock, LazyLock};
use std::collections::HashMap;

// OnceLock: инициализация при первом использовании через `get_or_init`.
// Полезно, когда начальное значение зависит от аргументов времени выполнения.
static CONFIG: OnceLock<HashMap<String, String>> = OnceLock::new();

fn get_config() -> &'static HashMap<String, String> {
    CONFIG.get_or_init(|| {
        // Дорогая операция: читаем и разбираем файл конфигурации. Выполняется ровно один раз.
        let mut m = HashMap::new();
        m.insert("log_level".into(), "info".into());
        m
    })
}

// LazyLock: инициализация при первом обращении, замыкание задаётся в месте определения.
// Эквивалент lazy_static!, но без макроса.
static REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
    regex::Regex::new(r"^[a-zA-Z0-9_]+$").unwrap()
});

fn is_valid_identifier(s: &str) -> bool {
    REGEX.is_match(s) // Первый вызов компилирует регулярное выражение; последующие переиспользуют его.
}
}
ТипСтабилизированМомент инициализацииКогда использовать
OnceLock<T>Rust 1.70В месте вызова (get_or_init)Инициализация зависит от аргументов времени выполнения
LazyLock<T>Rust 1.80В месте определения (замыкание)Инициализация самодостаточна
lazy_static!—В месте определения (макрос)Кодовые базы до 1.80 (лучше мигрировать)
const fn + staticВсегдаНа этапе компиляцииЗначение вычисляется на этапе компиляции

Совет по миграции: замените lazy_static! { static ref X: T = expr; } на static X: LazyLock<T> = LazyLock::new(|| expr);. Семантика та же, но без макроса и без внешней зависимости.

Паттерны без блокировок

Для высокопроизводительного кода полностью избегайте блокировок:

#![allow(unused)]
fn main() {
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;

// Паттерн 1: спин-лок (учебный пример, в продакшене используйте std::sync::Mutex)
// ⚠️ ВНИМАНИЕ: это только учебный пример. Настоящим спин-локам нужны:
//   - RAII-защитник (чтобы паника во время удержания не привела к вечной блокировке)
//   - Гарантии справедливости (при конкуренции этот вариант приводит к голоданию)
//   - Стратегии отката (экспоненциальная задержка, передача управления ОС)
// В продакшене используйте std::sync::Mutex или parking_lot::Mutex.
struct SpinLock {
    locked: AtomicBool,
}

impl SpinLock {
    fn new() -> Self { SpinLock { locked: AtomicBool::new(false) } }

    fn lock(&self) {
        while self.locked
            .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed)
            .is_err()
        {
            std::hint::spin_loop(); // Подсказка процессору: мы крутимся в цикле
        }
    }

    fn unlock(&self) {
        self.locked.store(false, Ordering::Release);
    }
}

// Паттерн 2: lock-free SPSC (один производитель, один потребитель)
// В продакшене используйте crossbeam::queue::ArrayQueue или аналог;
// писать свою реализацию стоит только для обучения.

// Паттерн 3: счётчик последовательности для чтений без ожидания (wait-free)
// ⚠️ Лучше всего для типов, помещающихся в одно машинное слово (u64, f64).
// Более широкие T могут быть прочитаны «порванными» (tearing).
struct SeqLock<T: Copy> {
    seq: AtomicUsize,
    data: std::cell::UnsafeCell<T>,
}

unsafe impl<T: Copy + Send> Sync for SeqLock<T> {}

impl<T: Copy> SeqLock<T> {
    fn new(val: T) -> Self {
        SeqLock {
            seq: AtomicUsize::new(0),
            data: std::cell::UnsafeCell::new(val),
        }
    }

    fn read(&self) -> T {
        loop {
            let s1 = self.seq.load(Ordering::Acquire);
            if s1 & 1 != 0 { continue; } // Записывающий поток в процессе, повторяем

            // SAFETY: используем ptr::read_volatile, чтобы компилятор не переупорядочивал
            // и не кэшировал чтение. Протокол SeqLock (проверка s1 == s2 после чтения)
            // гарантирует повтор, если писатель был активен.
            // Это повторяет паттерн C SeqLock, где чтение данных должно использовать
            // семантику volatile/relaxed, чтобы избежать «разрыва» значения при конкуренции.
            let value = unsafe { core::ptr::read_volatile(self.data.get() as *const T) };

            // Acquire-барьер: гарантирует, что чтение данных выше упорядочено раньше
            // повторной проверки счётчика последовательности.
            std::sync::atomic::fence(Ordering::Acquire);
            let s2 = self.seq.load(Ordering::Relaxed);

            if s1 == s2 { return value; } // Писатель не вмешался
            // иначе повторяем
        }
    }

    /// # Контракт безопасности
    /// Только ОДИН поток может вызывать `write()` одновременно. Если нужны несколько писателей,
    /// оберните вызов `write()` во внешний `Mutex`.
    fn write(&self, val: T) {
        // Увеличиваем до нечётного значения (сигнал: запись в процессе).
        // AcqRel: сторона Acquire не даёт последующей записи данных
        // переупорядочиться раньше этого инкремента (читатели должны увидеть
        // нечётное значение до того, как смогут наблюдать частичную запись). Сторона
        // Release формально не нужна для единственного писателя, но безвредна и согласована.
        self.seq.fetch_add(1, Ordering::AcqRel);
        // SAFETY: инвариант единственного писателя поддерживается вызывающим кодом (см. документацию выше).
        // UnsafeCell разрешает внутреннюю мутацию; счётчик seq защищает читателей.
        unsafe { *self.data.get() = val; }
        // Увеличиваем до чётного значения (сигнал: запись завершена).
        // Release: гарантирует, что запись данных видна раньше, чем читатели увидят чётный номер.
        self.seq.fetch_add(1, Ordering::Release);
    }
}
}

⚠️ Оговорка о модели памяти Rust: неатомарная запись через UnsafeCell в write(), выполняемая одновременно с неатомарным ptr::read_volatile в read(), формально является гонкой данных (data race) в абстрактной машине Rust, даже несмотря на то, что протокол SeqLock гарантирует повтор читателями при устаревших данных. Это повторяет паттерн SeqLock из ядра C и на всех современных процессорах корректно на практике для типов T, помещающихся в одно машинное слово (например, u64). Для более широких типов рассмотрите AtomicU64 для поля данных или оберните доступ в Mutex. Актуальные обсуждения по UnsafeCell в конкурентности см. в рекомендациях Rust по небезопасному коду.

Практический совет: код без блокировок трудно написать правильно. Используйте Mutex или RwLock, если профилирование не показывает, что узкое место именно в конкуренции за блокировки. Если блокировки без них действительно не обойтись, берите проверенные крейты (crossbeam, arc-swap, dashmap), а не пишите собственные реализации.

Ключевые выводы: конкурентность

  • Scoped-потоки (thread::scope) позволяют заимствовать данные со стека без Arc
  • rayon::par_iter() распараллеливает итераторы одним вызовом метода
  • Используйте OnceLock/LazyLock вместо lazy_static!; берите Mutex раньше, чем атомарные типы
  • Код без блокировок сложен: предпочитайте проверенные крейты собственным реализациям

См. также: гл. 5 — Каналы о конкурентности через передачу сообщений. гл. 9 — Умные указатели о подробностях Arc и Rc.

flowchart TD
    A["Нужно разделяемое<br>изменяемое состояние?"] -->|Да| B{"Насколько велика<br>конкуренция?"}
    A -->|Нет| C["Используйте каналы<br>(гл. 5)"]

    B -->|"Много чтений"| D["RwLock"]
    B -->|"Короткая критическая<br>секция"| E["Mutex"]
    B -->|"Простой счётчик<br>или флаг"| F["Атомарные типы"]
    B -->|"Сложное состояние"| G["Актор + каналы"]

    H["Нужен параллелизм?"] -->|"Обработка<br>коллекций"| I["rayon::par_iter"]
    H -->|"Фоновая задача"| J["thread::spawn"]
    H -->|"Заимствовать<br>локальные данные"| K["thread::scope"]

    style A fill:#e8f4f8,stroke:#2980b9,color:#000
    style B fill:#fef9e7,stroke:#f1c40f,color:#000
    style C fill:#d4efdf,stroke:#27ae60,color:#000
    style D fill:#fdebd0,stroke:#e67e22,color:#000
    style E fill:#fdebd0,stroke:#e67e22,color:#000
    style F fill:#fdebd0,stroke:#e67e22,color:#000
    style G fill:#fdebd0,stroke:#e67e22,color:#000
    style H fill:#e8f4f8,stroke:#2980b9,color:#000
    style I fill:#d4efdf,stroke:#27ae60,color:#000
    style J fill:#d4efdf,stroke:#27ae60,color:#000
    style K fill:#d4efdf,stroke:#27ae60,color:#000

Упражнение: параллельный map на scoped-потоках ★★ (~25 минут)

Напишите функцию parallel_map<T, R>(data: &[T], f: fn(&T) -> R, num_threads: usize) -> Vec<R>, которая делит data на num_threads частей и обрабатывает каждую часть в scoped-потоке. Не используйте rayon, используйте std::thread::scope.

🔑 Решение
fn parallel_map<T: Sync, R: Send>(data: &[T], f: fn(&T) -> R, num_threads: usize) -> Vec<R> {
    let chunk_size = (data.len() + num_threads - 1) / num_threads;
    let mut results = Vec::with_capacity(data.len());

    std::thread::scope(|s| {
        let mut handles = Vec::new();
        for chunk in data.chunks(chunk_size) {
            handles.push(s.spawn(move || {
                chunk.iter().map(f).collect::<Vec<_>>()
            }));
        }
        for h in handles {
            results.extend(h.join().unwrap());
        }
    });

    results
}

fn main() {
    let data: Vec<u64> = (1..=20).collect();
    let squares = parallel_map(&data, |x| x * x, 4);
    assert_eq!(squares, (1..=20).map(|x: u64| x * x).collect::<Vec<_>>());
    println!("Параллельные квадраты: {squares:?}");
}

7. Замыкания и функции высшего порядка 🟢

Что вы узнаете:

  • Три трейта замыканий (Fn, FnMut, FnOnce) и то, как устроен захват переменных
  • Передачу замыканий в качестве параметров и возврат их из функций
  • Цепочки комбинаторов и адаптеры итераторов для функционального стиля
  • Проектирование собственных API высшего порядка с правильными границами трейтов

Fn, FnMut, FnOnce: трейты замыканий

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

#![allow(unused)]
fn main() {
// FnOnce: потребляет захваченные значения (вызвать можно только один раз)
let name = String::from("Alice");
let greet = move || {
    println!("Hello, {name}!"); // Забирает владение `name`
    drop(name); // name потреблено
};
greet(); // ✅ Первый вызов
// greet(); // ❌ Нельзя вызвать снова: `name` уже потреблено

// FnMut: изменяемо заимствует захваченные значения (можно вызывать многократно)
let mut count = 0;
let mut increment = || {
    count += 1; // Изменяемо заимствует `count`
};
increment(); // count == 1
increment(); // count == 2

// Fn: неизменяемо заимствует захваченные значения (можно вызывать многократно и одновременно)
let prefix = "Result";
let display = |x: i32| {
    println!("{prefix}: {x}"); // Неизменяемо заимствует `prefix`
};
display(1);
display(2);
}

Иерархия: Fn : FnMut : FnOnce, каждый из них является подтрейтом следующего:

FnOnce  ← любое замыкание можно вызвать хотя бы один раз
 ↑
FnMut   ← можно вызывать многократно (может изменять состояние)
 ↑
Fn      ← можно вызывать многократно и одновременно (без изменения состояния)

Если замыкание реализует Fn, то оно реализует также FnMut и FnOnce.

Замыкания как параметры и возвращаемые значения

// --- Параметры ---

// Статическая диспетчеризация (мономорфизация, самая быстрая)
fn apply_twice<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 {
    f(f(x))
}

// То же, записанное через impl Trait:
fn apply_twice_v2(f: impl Fn(i32) -> i32, x: i32) -> i32 {
    f(f(x))
}

// Динамическая диспетчеризация (трейт-объект, гибко, с небольшими накладными расходами)
fn apply_dyn(f: &dyn Fn(i32) -> i32, x: i32) -> i32 {
    f(x)
}

// --- Возвращаемые значения ---

// Нельзя вернуть замыкание по значению без упаковки в Box (у него анонимный тип):
fn make_adder(n: i32) -> Box<dyn Fn(i32) -> i32> {
    Box::new(move |x| x + n)
}

// С impl Trait (проще, мономорфизируется, но динамически использовать нельзя):
fn make_adder_v2(n: i32) -> impl Fn(i32) -> i32 {
    move |x| x + n
}

fn main() {
    let double = |x: i32| x * 2;
    println!("{}", apply_twice(double, 3)); // 12

    let add5 = make_adder(5);
    println!("{}", add5(10)); // 15
}

Цепочки комбинаторов и адаптеры итераторов

Функции высшего порядка особенно хороши с итераторами: это идиоматичный Rust:

#![allow(unused)]
fn main() {
// Императивный цикл в стиле C:
let data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let mut result = Vec::new();
for x in &data {
    if x % 2 == 0 {
        result.push(x * x);
    }
}

// Идиоматичный Rust (функциональная цепочка комбинаторов):
let result: Vec<i32> = data.iter()
    .filter(|&&x| x % 2 == 0)
    .map(|&x| x * x)
    .collect();

// Производительность та же: итераторы ленивые и оптимизируются LLVM
assert_eq!(result, vec![4, 16, 36, 64, 100]);
}

Шпаргалка по распространённым комбинаторам:

КомбинаторЧто делаетПример
.map(f)Преобразует каждый элемент.map(|x| x * 2)
.filter(p)Оставляет элементы, для которых предикат истинен.filter(|x| x > &5)
.filter_map(f)Отображение и фильтрация за один шаг (возвращает Option).filter_map(|x| x.parse().ok())
.flat_map(f)Отображение с последующим разворачиванием вложенных итераторов.flat_map(|s| s.chars())
.fold(init, f)Свёртка в одно значение (аналог Aggregate в C#).fold(0, |acc, x| acc + x)
.any(p) / .all(p)Логическая проверка с ранним выходом.any(|x| x > 100)
.enumerate()Добавляет индекс.enumerate().map(|(i, x)| ...)
.zip(other)Объединяет в пары с другим итератором.zip(labels.iter())
.take(n) / .skip(n)Первые N элементов / пропуск N элементов.take(10)
.chain(other)Конкатенация двух итераторов.chain(extra.iter())
.peekable()Заглядывание вперёд без потребления.peek()
.collect()Собирает результат в коллекцию.collect::<Vec<_>>()

Собственные API высшего порядка

Проектируйте API, которые принимают замыкания для настройки:

#![allow(unused)]
fn main() {
/// Повторяет операцию с настраиваемой стратегией
fn retry<T, E, F, S>(
    mut operation: F,
    mut should_retry: S,
    max_attempts: usize,
) -> Result<T, E>
where
    F: FnMut() -> Result<T, E>,
    S: FnMut(&E, usize) -> bool, // (ошибка, попытка) → повторить ли?
{
    for attempt in 1..=max_attempts {
        match operation() {
            Ok(val) => return Ok(val),
            Err(e) if attempt < max_attempts && should_retry(&e, attempt) => {
                continue;
            }
            Err(e) => return Err(e),
        }
    }
    unreachable!()
}

// Использование: вызывающая сторона управляет логикой повторов:
}
#![allow(unused)]
fn main() {
fn connect_to_database() -> Result<(), String> { Ok(()) }
fn http_get(_url: &str) -> Result<String, String> { Ok(String::new()) }
trait TransientError { fn is_transient(&self) -> bool; }
impl TransientError for String { fn is_transient(&self) -> bool { true } }
let url = "http://example.com";
let result = retry(
    || connect_to_database(),
    |err, attempt| {
        eprintln!("Попытка {attempt} не удалась: {err}");
        true // Всегда повторять
    },
    3,
);

// Использование: повторять только определённые ошибки
let result = retry(
    || http_get(url),
    |err, _| err.is_transient(), // Повторять только временные ошибки
    5,
);
}

Паттерн with: доступ к ресурсу в скобках

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

настройка → вызов замыкания с ресурсом → откат

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

Пример: направление GPIO-пина

Контроллер GPIO управляет пинами, которые поддерживают двунаправленный ввод-вывод. Одним вызывающим нужно, чтобы пин был настроен на ввод, другим на вывод. Вместо того чтобы открывать прямой доступ к пину и надеяться, что вызывающие правильно выставят направление, контроллер предоставляет with_pin_input и with_pin_output:

/// Направление GPIO-пина: не публичное, вызывающие никогда не выставляют его напрямую.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Direction { In, Out }

/// Дескриптор GPIO-пина, переданный в замыкание. Его нельзя сохранить или клонировать:
/// он существует только на время вызова callback-функции.
pub struct GpioPin<'a> {
    pin_number: u8,
    _controller: &'a GpioController,
}

impl GpioPin<'_> {
    pub fn read(&self) -> bool {
        // Считываем уровень пина из аппаратного регистра
        println!("  чтение пина {}", self.pin_number);
        true // заглушка
    }

    pub fn write(&self, high: bool) {
        // Устанавливаем уровень пина через аппаратный регистр
        println!("  запись в пин {} = {high}", self.pin_number);
    }
}

pub struct GpioController {
    current_direction: std::cell::Cell<Option<Direction>>,
}

impl GpioController {
    pub fn new() -> Self {
        GpioController {
            current_direction: std::cell::Cell::new(None),
        }
    }

    /// Настраиваем пин на ввод, выполняем замыкание, восстанавливаем состояние.
    /// Вызывающая сторона получает `GpioPin`, который живёт только на время callback-функции.
    pub fn with_pin_input<R>(
        &self,
        pin: u8,
        mut f: impl FnMut(&GpioPin<'_>) -> R,
    ) -> R {
        let prev = self.current_direction.get();
        self.set_direction(pin, Direction::In);
        let handle = GpioPin { pin_number: pin, _controller: self };
        let result = f(&handle);
        // Восстанавливаем предыдущее направление (или оставляем как есть: вопрос политики)
        if let Some(dir) = prev {
            self.set_direction(pin, dir);
        }
        result
    }

    /// Настраиваем пин на вывод, выполняем замыкание, восстанавливаем состояние.
    pub fn with_pin_output<R>(
        &self,
        pin: u8,
        mut f: impl FnMut(&GpioPin<'_>) -> R,
    ) -> R {
        let prev = self.current_direction.get();
        self.set_direction(pin, Direction::Out);
        let handle = GpioPin { pin_number: pin, _controller: self };
        let result = f(&handle);
        if let Some(dir) = prev {
            self.set_direction(pin, dir);
        }
        result
    }

    fn set_direction(&self, pin: u8, dir: Direction) {
        println!("  [hw] пин {pin} → {dir:?}");
        self.current_direction.set(Some(dir));
    }
}

fn main() {
    let gpio = GpioController::new();

    // Вызывающая сторона 1: нужен ввод. Ей не важно и не нужно знать, как управляется направление
    let level = gpio.with_pin_input(4, |pin| {
        pin.read()
    });
    println!("Уровень пина 4: {level}");

    // Вызывающая сторона 2: нужен вывод. Форма API та же, гарантия другая
    gpio.with_pin_output(4, |pin| {
        pin.write(true);
        // делаем другую работу...
        pin.write(false);
    });

    // Нельзя использовать дескриптор пина вне замыкания:
    // let escaped_pin = gpio.with_pin_input(4, |pin| pin);
    // ❌ ERROR: borrowed value does not live long enough
}

Что гарантирует паттерн with:

  • Направление всегда выставляется до выполнения кода вызывающей стороны
  • Направление всегда восстанавливается после, даже если замыкание вернуло управление досрочно
  • Дескриптор GpioPin не может выйти за пределы замыкания: заимствующая проверка обеспечивает это через время жизни, привязанное к ссылке на контроллер
  • Вызывающим не нужно импортировать Direction или вызывать set_direction: API невозможно использовать неправильно

Где встречается этот паттерн

Паттерн with встречается по всей стандартной библиотеке Rust и экосистеме:

APIНастройкаЗамыкание (callback)Откат
std::thread::scopeСоздание области|s| { s.spawn(...) }Ожидание всех потоков
Mutex::lockЗахват блокировкиИспользование MutexGuard (RAII, не замыкание, но та же идея)Освобождение при уничтожении
tempfile::tempdirСоздание временного каталогаИспользование путиУдаление при уничтожении
std::io::BufWriter::newБуферизация записиОперации записиСброс буфера при уничтожении
GPIO with_pin_* (выше)Выставление направленияИспользование дескриптора пинаВосстановление направления

Вариант на основе замыканий наиболее эффективен, когда:

  • настройка и откат идут парами, и пропуск любого из них является ошибкой
  • ресурс не должен жить дольше операции: заимствующая проверка обеспечивает это естественным образом
  • существует несколько конфигураций (with_pin_input и with_pin_output): каждый метод with_* инкапсулирует свою настройку и не раскрывает конфигурацию вызывающей стороне

with и RAII (Drop): оба варианта гарантируют очистку. Используйте RAII / Drop, когда вызывающему нужно удерживать ресурс через несколько инструкций и вызовов функций. Используйте with, когда операция заключена в скобки: одна настройка, один блок работы, один откат, и вы не хотите, чтобы вызывающий мог нарушить эти скобки.

FnMut или Fn в дизайне API: используйте FnMut как границу по умолчанию. Это самый гибкий вариант: вызывающие могут передавать и Fn, и FnMut. Требуйте Fn, только если нужно вызывать замыкание одновременно (например, из нескольких потоков). Требуйте FnOnce, только если вызываете его ровно один раз.

Ключевые выводы: замыкания

  • Fn заимствует, FnMut заимствует изменяемо, FnOnce потребляет: принимайте самую слабую границу, которая нужна вашему API
  • impl Fn в параметрах, Box<dyn Fn> для хранения, impl Fn в возвращаемом значении (или Box<dyn Fn>, если нужна динамика)
  • Цепочки комбинаторов (map, filter, and_then) чисто компонуются и инлайнятся в плотные циклы
  • Паттерн with (доступ в скобках через замыкание) гарантирует настройку и откат и не даёт ресурсу «утечь»: используйте его, когда вызывающий не должен управлять жизненным циклом конфигурации

См. также: гл. 2 — Трейты в деталях о том, как Fn/FnMut/FnOnce связаны с трейт-объектами. гл. 8 — Функциональный и императивный стили о том, когда выбирать комбинаторы вместо циклов. гл. 15 — Дизайн API о паттернах эргономичных параметров.

graph TD
    FnOnce["FnOnce<br>(можно вызвать один раз)"]
    FnMut["FnMut<br>(можно вызывать многократно,<br>может изменять захваченное)"]
    Fn["Fn<br>(можно вызывать многократно,<br>захваченное неизменяемо)"]

    Fn -->|"реализует"| FnMut
    FnMut -->|"реализует"| FnOnce

    style Fn fill:#d4efdf,stroke:#27ae60,color:#000
    style FnMut fill:#fef9e7,stroke:#f1c40f,color:#000
    style FnOnce fill:#fadbd8,stroke:#e74c3c,color:#000

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


Упражнение: конвейер комбинаторов высшего порядка ★★ (~25 минут)

Создайте структуру Pipeline, которая связывает преобразования в цепочку. Она должна поддерживать .pipe(f) для добавления преобразования и .execute(input) для выполнения всей цепочки.

🔑 Решение
struct Pipeline<T> {
    transforms: Vec<Box<dyn Fn(T) -> T>>,
}

impl<T: 'static> Pipeline<T> {
    fn new() -> Self {
        Pipeline { transforms: Vec::new() }
    }

    fn pipe(mut self, f: impl Fn(T) -> T + 'static) -> Self {
        self.transforms.push(Box::new(f));
        self
    }

    fn execute(self, input: T) -> T {
        self.transforms.into_iter().fold(input, |val, f| f(val))
    }
}

fn main() {
    let result = Pipeline::new()
        .pipe(|s: String| s.trim().to_string())
        .pipe(|s| s.to_uppercase())
        .pipe(|s| format!(">>> {s} <<<"))
        .execute("  hello world  ".to_string());

    println!("{result}"); // >>> HELLO WORLD <<<

    let result = Pipeline::new()
        .pipe(|x: i32| x * 2)
        .pipe(|x| x + 10)
        .pipe(|x| x * x)
        .execute(5);

    println!("{result}"); // (5*2 + 10)^2 = 400
}

8. Функциональный и императивный стили: когда выигрывает изящество (и когда нет)

Сложность: 🟡 Средний | Время: 2–3 часа | Предварительные знания: гл. 7 — Замыкания

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

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


8.1 Комбинатор, о котором вы не знали, что он вам нужен

Многие разработчики на Rust пишут так:

#![allow(unused)]
fn main() {
let value = if let Some(x) = maybe_config() {
    x
} else {
    default_config()
};
process(value);
}

Хотя могли бы написать так:

#![allow(unused)]
fn main() {
process(maybe_config().unwrap_or_else(default_config));
}

Или вот такой распространённый шаблон:

#![allow(unused)]
fn main() {
let display_name = if let Some(name) = user.nickname() {
    name.to_uppercase()
} else {
    "ANONYMOUS".to_string()
};
}

Который эквивалентен:

#![allow(unused)]
fn main() {
let display_name = user.nickname()
    .map(|n| n.to_uppercase())
    .unwrap_or_else(|| "ANONYMOUS".to_string());
}

Функциональная версия не просто короче: она сразу говорит, что происходит (преобразовать, затем подставить значение по умолчанию), и не заставляет прослеживать поток управления. Версия с if let заставляет читать каждую ветку, чтобы понять, что оба пути приводят в одно и то же место.

Семейство комбинаторов Option

Модель в голове такая: Option<T> — это коллекция из одного элемента или пустая коллекция. У каждого комбинатора Option есть аналогия с операцией над коллекцией.

Вы пишете…Вместо…Что это сообщает
opt.unwrap_or(default)if let Some(x) = opt { x } else { default }«Используй это значение или запасной вариант»
opt.unwrap_or_else(|| expensive())if let Some(x) = opt { x } else { expensive() }То же, но значение по умолчанию вычисляется лениво
opt.map(f)match opt { Some(x) => Some(f(x)), None => None }«Преобразовать содержимое, сохранить отсутствие»
opt.and_then(f)match opt { Some(x) => f(x), None => None }«Связать операции, которые могут не удаться» (flatmap)
opt.filter(|x| pred(x))match opt { Some(x) if pred(&x) => Some(x), _ => None }«Оставить, только если проходит проверку»
opt.zip(other)if let (Some(a), Some(b)) = (opt, other) { Some((a,b)) } else { None }«Оба или ни одного»
opt.or(fallback)if opt.is_some() { opt } else { fallback }«Первое доступное»
opt.or_else(|| try_another())if opt.is_some() { opt } else { try_another() }«Пробовать альтернативы по порядку»
opt.map_or(default, f)if let Some(x) = opt { f(x) } else { default }«Преобразовать или подставить значение по умолчанию»: одна строка
opt.map_or_else(default_fn, f)if let Some(x) = opt { f(x) } else { default_fn() }То же, но обе стороны — замыкания
opt?match opt { Some(x) => x, None => return None }«Передать отсутствие вверх по стеку»

Семейство комбинаторов Result

Тот же паттерн применим к Result<T, E>:

Вы пишете…Вместо…Что это сообщает
res.map(f)match res { Ok(x) => Ok(f(x)), Err(e) => Err(e) }Преобразовать путь успеха
res.map_err(f)match res { Ok(x) => Ok(x), Err(e) => Err(f(e)) }Преобразовать ошибку
res.and_then(f)match res { Ok(x) => f(x), Err(e) => Err(e) }Связать операции, которые могут не удаться
res.unwrap_or_else(|e| default(e))match res { Ok(x) => x, Err(e) => default(e) }Восстановиться после ошибки
res.ok()match res { Ok(x) => Some(x), Err(_) => None }«Ошибка мне не важна»
res?match res { Ok(x) => x, Err(e) => return Err(e.into()) }Передать ошибку вверх по стеку

Когда if let лучше

Комбинаторы проигрывают, когда:

  • В ветке Some нужно несколько инструкций. Замыкание map на 5 строк хуже, чем if let на 5 строк.
  • Сам поток управления и есть суть. if let Some(connection) = pool.try_get() { /* использовать */ } else { /* записать в журнал, повторить, оповестить */ }: обе ветки — действительно разные пути выполнения, а не «преобразовать или подставить значение по умолчанию».
  • Доминируют побочные эффекты. Если обе ветки выполняют ввод-вывод с разной обработкой ошибок, версия с комбинаторами скрывает важные различия.

Практическое правило: если ветка else даёт тот же тип, что и ветка Some, а тела короткие, используйте комбинатор. Если ветки делают принципиально разные вещи, используйте if let или match.


8.2 Булевы комбинаторы: .then() и .then_some()

Ещё один шаблон, который встречается чаще, чем следовало бы:

#![allow(unused)]
fn main() {
let label = if is_admin {
    Some("ADMIN")
} else {
    None
};
}

Начиная с Rust 1.62, можно написать так:

#![allow(unused)]
fn main() {
let label = is_admin.then_some("ADMIN");
}

Или с вычисляемым значением:

#![allow(unused)]
fn main() {
let permissions = is_admin.then(|| compute_admin_permissions());
}

Это особенно полезно в цепочках:

#![allow(unused)]
fn main() {
// Императивно
let mut tags = Vec::new();
if user.is_admin { tags.push("admin"); }
if user.is_verified { tags.push("verified"); }
if user.score > 100 { tags.push("power-user"); }

// Функционально
let tags: Vec<&str> = [
    user.is_admin.then_some("admin"),
    user.is_verified.then_some("verified"),
    (user.score > 100).then_some("power-user"),
]
.into_iter()
.flatten()
.collect();
}

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


8.3 Цепочки итераторов и циклы: система принятия решений

В главе 7 показана механика. Этот раздел помогает выработать суждение.

Когда выигрывают итераторы

Конвейеры данных: преобразование коллекции через последовательность шагов:

#![allow(unused)]
fn main() {
// Императивно: 8 строк, 2 изменяемые переменные
let mut results = Vec::new();
for item in inventory {
    if item.category == Category::Server {
        if let Some(temp) = item.last_temperature() {
            if temp > 80.0 {
                results.push((item.id, temp));
            }
        }
    }
}

// Функционально: 6 строк, 0 изменяемых переменных, один конвейер
let results: Vec<_> = inventory.iter()
    .filter(|item| item.category == Category::Server)
    .filter_map(|item| item.last_temperature().map(|t| (item.id, t)))
    .filter(|(_, temp)| *temp > 80.0)
    .collect();
}

Функциональная версия выигрывает, потому что:

  • Каждый фильтр читается независимо
  • Нет mut: данные текут в одном направлении
  • Этапы конвейера можно добавлять, удалять и переставлять без перестройки кода
  • LLVM инлайнит адаптеры итераторов в тот же машинный код, что и цикл

Агрегация: вычисление одного значения из коллекции:

#![allow(unused)]
fn main() {
// Императивно
let mut total_power = 0.0;
let mut count = 0;
for server in fleet {
    total_power += server.power_draw();
    count += 1;
}
let avg = total_power / count as f64;

// Функционально
let (total_power, count) = fleet.iter()
    .map(|s| s.power_draw())
    .fold((0.0, 0usize), |(sum, n), p| (sum + p, n + 1));
let avg = total_power / count as f64;
}

Или даже проще, если нужна только сумма:

#![allow(unused)]
fn main() {
let total: f64 = fleet.iter().map(|s| s.power_draw()).sum();
}

Когда выигрывают циклы

Ранний выход со сложным состоянием:

#![allow(unused)]
fn main() {
// Это понятно и прямолинейно
let mut best_candidate = None;
for server in fleet {
    let score = evaluate(server);
    if score > threshold {
        if server.is_available() {
            best_candidate = Some(server);
            break; // Нашли один: останавливаемся сразу
        }
    }
}

// Функциональная версия выглядит натянуто
let best_candidate = fleet.iter()
    .filter(|s| evaluate(s) > threshold)
    .find(|s| s.is_available());
}

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

Одновременное формирование нескольких результатов:

#![allow(unused)]
fn main() {
// Императивно: понятно, каждая ветка делает своё
let mut warnings = Vec::new();
let mut errors = Vec::new();
let mut stats = Stats::default();

for event in log_stream {
    match event.severity {
        Severity::Warn => {
            warnings.push(event.clone());
            stats.warn_count += 1;
        }
        Severity::Error => {
            errors.push(event.clone());
            stats.error_count += 1;
            if event.is_critical() {
                alert_oncall(&event);
            }
        }
        _ => stats.other_count += 1,
    }
}

// Функциональная версия: вынужденная, неуклюжая, её никто не захочет читать
let (warnings, errors, stats) = log_stream.iter().fold(
    (Vec::new(), Vec::new(), Stats::default()),
    |(mut w, mut e, mut s), event| {
        match event.severity {
            Severity::Warn => { w.push(event.clone()); s.warn_count += 1; }
            Severity::Error => {
                e.push(event.clone()); s.error_count += 1;
                if event.is_critical() { alert_oncall(event); }
            }
            _ => s.other_count += 1,
        }
        (w, e, s)
    },
);
}

Версия с fold длиннее, труднее читается и всё равно содержит мутацию (деструктурированные изменяемые аккумуляторы). Цикл выигрывает, потому что:

  • Несколько результатов строятся параллельно
  • Побочные эффекты (оповещение) смешаны с логикой
  • Тела ветвей это инструкции, а не выражения

Конечные автоматы с вводом-выводом:

#![allow(unused)]
fn main() {
// Парсер, который читает токены: цикл и есть алгоритм
let mut state = ParseState::Start;
loop {
    let token = lexer.next_token()?;
    state = match state {
        ParseState::Start => match token {
            Token::Keyword(k) => ParseState::GotKeyword(k),
            Token::Eof => break,
            _ => return Err(ParseError::UnexpectedToken(token)),
        },
        ParseState::GotKeyword(k) => match token {
            Token::Ident(name) => ParseState::GotName(k, name),
            _ => return Err(ParseError::ExpectedIdentifier),
        },
        // ...остальные состояния
    };
}
}

Чище функциональной альтернативы не получится. Цикл с match state это естественная запись конечного автомата.

Блок-схема выбора

flowchart TB
    START{Что вы делаете?}

    START -->|"Преобразование коллекции<br/>в другую коллекцию"| PIPE[Цепочка итераторов]
    START -->|"Вычисление одного значения<br/>из коллекции"| AGG{Насколько сложно?}
    START -->|"Несколько результатов<br/>за один проход"| LOOP[Цикл for]
    START -->|"Конечный автомат с<br/>I/O или побочными эффектами"| LOOP
    START -->|"Один Option/Result:<br/>преобразование + значение по умолчанию"| COMB[Комбинаторы]

    AGG -->|"Сумма, количество, мин, макс"| BUILTIN["Используйте .sum(), .count(),<br/>.min(), .max()"]
    AGG -->|"Произвольная аккумуляция"| FOLD{В аккумуляторе есть мутация<br/>или побочные эффекты?}
    FOLD -->|"Нет"| FOLDF["Используйте .fold()"]
    FOLD -->|"Да"| LOOP

    style PIPE fill:#d4efdf,stroke:#27ae60,color:#000
    style COMB fill:#d4efdf,stroke:#27ae60,color:#000
    style BUILTIN fill:#d4efdf,stroke:#27ae60,color:#000
    style FOLDF fill:#d4efdf,stroke:#27ae60,color:#000
    style LOOP fill:#fef9e7,stroke:#f1c40f,color:#000

Врезка: ограниченная изменяемость, императивно внутри и функционально снаружи

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

#![allow(unused)]
fn main() {
use rand::random;

let samples = {
    let mut buf = Vec::with_capacity(10);
    while buf.len() < 10 {
        let reading: f64 = random();
        buf.push(reading);
        if random::<u8>() % 3 == 0 { break; } // случайная досрочная остановка
    }
    buf
};
// samples неизменяем: содержит от 1 до 10 элементов
}

Внутренний buf изменяем только внутри блока. Когда блок возвращает значение, внешняя привязка samples становится неизменяемой, и компилятор отклонит любой последующий вызов samples.push(...).

Почему не цепочка итераторов? Можно попробовать так:

#![allow(unused)]
fn main() {
let samples: Vec<f64> = std::iter::from_fn(|| Some(random()))
    .take(10)
    .take_while(|_| random::<u8>() % 3 != 0)
    .collect();
}

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

Когда ограниченная изменяемость действительно выигрывает:

СценарийПочему итераторам трудно
Сортировка, затем фиксация (sort_unstable() + dedup())Оба возвращают (): нет цепочечного результата (itertools предлагает .sorted().dedup(), если доступен)
Остановка по состоянию (остановка по условию, не связанному с данными)take_while отбрасывает граничный элемент
Пошаговое заполнение структуры (поле за полем из разных источников)Нет естественного единого конвейера

Честная оценка: для большинства задач построения коллекций предпочтительны цепочки итераторов или itertools. Прибегайте к ограниченной изменяемости, когда логика построения содержит ветвления, ранний выход или мутацию на месте, которая не укладывается в один конвейер. Настоящая ценность этого паттерна в том, что область видимости мутации может быть меньше времени жизни переменной. Это основополагающая особенность Rust, которая удивляет разработчиков, пришедших из C++, C# и Python.


8.4 Оператор ?: там, где функциональное встречается с императивным

Оператор ? это самый элегантный синтез обоих стилей в Rust. По сути это .and_then() в сочетании с ранним возвратом:

#![allow(unused)]
fn main() {
// Эта цепочка and_then...
fn load_config() -> Result<Config, Error> {
    read_file("config.toml")
        .and_then(|contents| parse_toml(&contents))
        .and_then(|table| validate_config(table))
        .and_then(|valid| Config::from_validated(valid))
}

// ...в точности эквивалентна этой
fn load_config() -> Result<Config, Error> {
    let contents = read_file("config.toml")?;
    let table = parse_toml(&contents)?;
    let valid = validate_config(table)?;
    Config::from_validated(valid)
}
}

Обе версии по духу функциональны (они автоматически передают ошибки), но версия с ? даёт именованные промежуточные переменные. Это важно, когда:

  • Вам снова нужна contents позже
  • Вы хотите добавить .context("при разборе конфигурации")? на каждом шаге
  • Вы отлаживаете код и хотите посмотреть промежуточные значения

Антипаттерн: длинные цепочки .and_then(), когда доступен ?. Если каждое замыкание в цепочке имеет вид |x| next_step(x), вы заново изобрели ? без читаемости.

Когда .and_then() лучше, чем ?:

#![allow(unused)]
fn main() {
// Преобразование внутри Option без раннего возврата
let port: Option<u16> = config.get("port")
    .and_then(|v| v.parse::<u16>().ok())
    .filter(|&p| p > 0 && p < 65535);
}

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


8.5 Построение коллекций: collect() и циклы с push

collect() мощнее, чем обычно думают разработчики:

Сбор в Result

#![allow(unused)]
fn main() {
// Императивно: разбираем список, падаем на первой ошибке
let mut numbers = Vec::new();
for s in input_strings {
    let n: i64 = s.parse().map_err(|_| Error::BadInput(s.clone()))?;
    numbers.push(n);
}

// Функционально: собираем в Result<Vec<_>, _>
let numbers: Vec<i64> = input_strings.iter()
    .map(|s| s.parse::<i64>().map_err(|_| Error::BadInput(s.clone())))
    .collect::<Result<_, _>>()?;
}

Приём collect::<Result<Vec<_>, _>>() работает, потому что Result реализует FromIterator. Он прекращает работу на первом Err, точно так же, как цикл с ?.

Сбор в HashMap

#![allow(unused)]
fn main() {
// Императивно
let mut index = HashMap::new();
for server in fleet {
    index.insert(server.id.clone(), server);
}

// Функционально
let index: HashMap<_, _> = fleet.into_iter()
    .map(|s| (s.id.clone(), s))
    .collect();
}

Сбор в String

#![allow(unused)]
fn main() {
// Императивно
let mut csv = String::new();
for (i, field) in fields.iter().enumerate() {
    if i > 0 { csv.push(','); }
    csv.push_str(field);
}

// Функционально
let csv = fields.join(",");

// Или для более сложного форматирования:
let csv: String = fields.iter()
    .map(|f| format!("\"{f}\""))
    .collect::<Vec<_>>()
    .join(",");
}

Когда выигрывает версия с циклом

collect() выделяет новую коллекцию. Если вы меняете на месте, цикл одновременно понятнее и эффективнее:

#![allow(unused)]
fn main() {
// Обновление на месте: функционального аналога, который был бы лучше, нет
for server in &mut fleet {
    if server.needs_refresh() {
        server.refresh_telemetry()?;
    }
}
}

Функциональная версия потребовала бы .iter_mut().for_each(|s| { ... }), а это просто цикл с лишним синтаксисом.


8.6 Сопоставление с образцом как диспетчеризация функций

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

match как таблица поиска

#![allow(unused)]
fn main() {
// Императивное мышление: «проверяем каждый случай»
fn status_message(code: StatusCode) -> &'static str {
    if code == StatusCode::OK { "Success" }
    else if code == StatusCode::NOT_FOUND { "Not found" }
    else if code == StatusCode::INTERNAL { "Server error" }
    else { "Unknown" }
}

// Функциональное мышление: «отображение из области определения в область значений»
fn status_message(code: StatusCode) -> &'static str {
    match code {
        StatusCode::OK => "Success",
        StatusCode::NOT_FOUND => "Not found",
        StatusCode::INTERNAL => "Server error",
        _ => "Unknown",
    }
}
}

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

Сопоставление с образцом и деструктуризация как конвейер

#![allow(unused)]
fn main() {
// Разбор команды: каждая ветка извлекает данные и преобразует их
fn execute(cmd: Command) -> Result<Response, Error> {
    match cmd {
        Command::Get { key } => db.get(&key).map(Response::Value),
        Command::Set { key, value } => db.set(key, value).map(|_| Response::Ok),
        Command::Delete { key } => db.delete(&key).map(|_| Response::Ok),
        Command::Batch(cmds) => cmds.into_iter()
            .map(execute)
            .collect::<Result<Vec<_>, _>>()
            .map(Response::Batch),
    }
}
}

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


8.7 Цепочки методов на собственных типах

Функциональный стиль выходит за пределы типов стандартной библиотеки. Паттерны builder и fluent API это функциональное программирование в маскировке:

#![allow(unused)]
fn main() {
// Это цепочка комбинаторов над вашим собственным типом
let query = QueryBuilder::new("servers")
    .filter("status", Eq, "active")
    .filter("rack", In, &["A1", "A2", "B1"])
    .order_by("temperature", Desc)
    .limit(50)
    .build();
}

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

#![allow(unused)]
fn main() {
// Хорошо: цепочка работает, потому что каждый шаг это простое преобразование
let config = Config::default()
    .with_timeout(Duration::from_secs(30))
    .with_retries(3)
    .with_tls(true);

// Плохо: цепочка работает, но делает слишком много не связанных между собой вещей
let result = processor
    .load_data(path)?       // I/O
    .validate()             // Чисто
    .transform(rule_set)    // Чисто
    .save_to_disk(output)?  // I/O
    .notify_downstream()?;  // Побочный эффект

// Лучше: отделить чистый конвейер от I/O на концах
let data = load_data(path)?;
let processed = data.validate().transform(rule_set);
save_to_disk(output, &processed)?;
notify_downstream()?;
}

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


8.8 Производительность: они одинаковы

Распространённое заблуждение: «функциональный стиль медленнее из-за всех этих замыканий и выделений памяти».

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

#![allow(unused)]
fn main() {
// На release-сборках эти варианты дают идентичный ассемблер:

// Функционально
let sum: i64 = (0..1000).filter(|n| n % 2 == 0).map(|n| n * n).sum();

// Императивно
let mut sum: i64 = 0;
for n in 0..1000 {
    if n % 2 == 0 {
        sum += n * n;
    }
}
}

Единственное исключение: .collect() выделяет память. Если вы строите цепочку .map().collect().iter().map().collect() с промежуточными коллекциями, вы платите за выделения, которых избежала бы версия с циклом. Решение: убрать промежуточные collect и соединить адаптеры напрямую или использовать цикл, если промежуточные коллекции нужны по другим причинам.


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

Справочная таблица для самых частых шаблонов «я написал 6 строк, а есть однострочник»:

Императивный шаблонФункциональный аналогКогда предпочесть функциональный
if let Some(x) = opt { f(x) } else { default }opt.map_or(default, f)Короткие выражения с обеих сторон
if let Some(x) = opt { Some(g(x)) } else { None }opt.map(g)Всегда: для этого и существует map
if condition { Some(x) } else { None }condition.then_some(x)Всегда
if condition { Some(compute()) } else { None }condition.then(compute)Всегда
match opt { Some(x) if pred(x) => Some(x), _ => None }opt.filter(pred)Всегда
for x in iter { if pred(x) { result.push(f(x)); } }iter.filter(pred).map(f).collect()Когда конвейер читается с одного экрана
if a.is_some() && b.is_some() { Some((a?, b?)) }a.zip(b)Всегда: .zip() делает ровно это
match (a, b) { (Some(x), Some(y)) => x + y, _ => 0 }a.zip(b).map(|(x,y)| x + y).unwrap_or(0)Вопрос суждения, зависит от сложности
iter.map(f).collect::<Vec<_>>()[0]iter.map(f).next().unwrap()Не выделяйте Vec под один элемент
let mut v = vec; v.sort(); v{ let mut v = vec; v.sort(); v }В стандартной библиотеке нет .sorted() (используйте itertools)

8.10 Антипаттерны

Чрезмерная функциональность: цепочка из пяти уровней, которую никто не может прочитать

#![allow(unused)]
fn main() {
// Это не элегантно. Это головоломка.
let result = data.iter()
    .filter_map(|x| x.metadata.as_ref())
    .flat_map(|m| m.tags.iter())
    .filter(|t| t.starts_with("env:"))
    .map(|t| t.strip_prefix("env:").unwrap())
    .filter(|env| allowed_envs.contains(env))
    .map(|env| env.to_uppercase())
    .collect::<HashSet<_>>()
    .into_iter()
    .sorted()
    .collect::<Vec<_>>();
}

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

#![allow(unused)]
fn main() {
let env_tags = data.iter()
    .filter_map(|x| x.metadata.as_ref())
    .flat_map(|m| m.tags.iter());

let allowed: Vec<_> = env_tags
    .filter_map(|t| t.strip_prefix("env:"))
    .filter(|env| allowed_envs.contains(env))
    .map(|env| env.to_uppercase())
    .sorted()
    .collect();
}

Недостаточная функциональность: цикл в стиле C, для которого у Rust есть слово

#![allow(unused)]
fn main() {
// Это просто .any()
let mut found = false;
for item in &list {
    if item.is_expired() {
        found = true;
        break;
    }
}

// Напишите вместо этого
let found = list.iter().any(|item| item.is_expired());
}
#![allow(unused)]
fn main() {
// Это просто .find()
let mut target = None;
for server in &fleet {
    if server.id == target_id {
        target = Some(server);
        break;
    }
}

// Напишите вместо этого
let target = fleet.iter().find(|s| s.id == target_id);
}
#![allow(unused)]
fn main() {
// Это просто .all()
let mut all_healthy = true;
for server in &fleet {
    if !server.is_healthy() {
        all_healthy = false;
        break;
    }
}

// Напишите вместо этого
let all_healthy = fleet.iter().all(|s| s.is_healthy());
}

В стандартной библиотеке эти методы есть не случайно. Выучите словарь, и паттерны станут очевидны.


Ключевые выводы

  • Option и Result это коллекции из одного элемента. Их комбинаторы (.map(), .and_then(), .unwrap_or_else(), .filter(), .zip()) заменяют большую часть шаблонного кода с if let и match.
  • Используйте bool::then_some(): он заменяет if cond { Some(x) } else { None } в любом случае.
  • Цепочки итераторов выигрывают для конвейеров данных: filter, map и collect без изменяемого состояния. Они компилируются в тот же машинный код, что и циклы.
  • Циклы выигрывают для автоматов с несколькими результатами: когда вы строите несколько коллекций, выполняете I/O в ветках или управляете переходом состояния.
  • Оператор ? это лучшее из двух миров: функциональная передача ошибок с императивной читаемостью.
  • Разбивайте цепочки примерно на 4 адаптерах: используйте именованные промежуточные переменные для читаемости. Чрезмерная функциональность так же вредна, как недостаточная.
  • Выучите словарь стандартной библиотеки: .any(), .all(), .find(), .position(), .sum(), .min_by_key(). Каждый из них заменяет многострочный цикл одним вызовом, который выражает намерение.

См. также: гл. 7 о механике замыканий и иерархии трейта Fn. гл. 10 о комбинаторах для обработки ошибок. гл. 15 о проектировании fluent API.


Упражнение: рефакторинг императивного кода в функциональный ★★ (~30 мин)

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

#![allow(unused)]
fn main() {
fn summarize_fleet(fleet: &[Server]) -> FleetSummary {
    let mut healthy = Vec::new();
    let mut degraded = Vec::new();
    let mut failed = Vec::new();
    let mut total_power = 0.0;
    let mut max_temp = f64::NEG_INFINITY;

    for server in fleet {
        match server.health_status() {
            Health::Healthy => healthy.push(server.id.clone()),
            Health::Degraded(reason) => degraded.push((server.id.clone(), reason)),
            Health::Failed(err) => failed.push((server.id.clone(), err)),
        }
        total_power += server.power_draw();
        if server.max_temperature() > max_temp {
            max_temp = server.max_temperature();
        }
    }

    FleetSummary {
        healthy,
        degraded,
        failed,
        avg_power: total_power / fleet.len() as f64,
        max_temp,
    }
}
}
🔑 Решение

total_power и max_temp — чистые функциональные переписывания:

#![allow(unused)]
fn main() {
fn summarize_fleet(fleet: &[Server]) -> FleetSummary {
    let avg_power: f64 = fleet.iter().map(|s| s.power_draw()).sum::<f64>()
        / fleet.len() as f64;

    let max_temp = fleet.iter()
        .map(|s| s.max_temperature())
        .fold(f64::NEG_INFINITY, f64::max);

    // Но разбиение на три группы ЛУЧШЕ сделать циклом.
    // Функциональная версия потребовала бы трёх отдельных проходов
    // или неуклюжего fold с тремя изменяемыми аккумуляторами.
    let mut healthy = Vec::new();
    let mut degraded = Vec::new();
    let mut failed = Vec::new();

    for server in fleet {
        match server.health_status() {
            Health::Healthy => healthy.push(server.id.clone()),
            Health::Degraded(reason) => degraded.push((server.id.clone(), reason)),
            Health::Failed(err) => failed.push((server.id.clone(), err)),
        }
    }

    FleetSummary { healthy, degraded, failed, avg_power, max_temp }
}
}

Почему цикл лучше для разбиения на три группы: функциональная версия либо потребовала бы три прохода .filter().collect() (трёхкратный обход), либо .fold() с тремя mut Vec-аккумуляторами внутри кортежа, то есть тот же цикл, переписанный с худшим синтаксисом. Императивный цикл за один проход понятнее, эффективнее и проще расширять.


9. Умные указатели и внутренняя изменяемость 🟡

Что вы узнаете:

  • Box, Rc и Arc для размещения в куче и совместного владения
  • Слабые ссылки (Weak) для разрыва циклов ссылок в Rc/Arc
  • Cell, RefCell и Cow для паттернов внутренней изменяемости
  • Pin для самоссылающихся типов и ManuallyDrop для управления жизненным циклом

Box, Rc, Arc: размещение в куче и совместное использование

#![allow(unused)]
fn main() {
// --- Box<T>: единственный владелец, размещение в куче ---
// Использовать, когда нужны рекурсивные типы, большие значения или трейт-объекты
let boxed: Box<i32> = Box::new(42);
println!("{}", *boxed); // Deref к i32

// Рекурсивный тип требует Box (иначе размер бесконечен):
enum List<T> {
    Cons(T, Box<List<T>>),
    Nil,
}

// Трейт-объект (динамическая диспетчеризация):
let writer: Box<dyn std::io::Write> = Box::new(std::io::stdout());

// --- Rc<T>: несколько владельцев, один поток ---
// Использовать, когда нужно совместное владение внутри одного потока (без Send/Sync)
use std::rc::Rc;

let a = Rc::new(vec![1, 2, 3]);
let b = Rc::clone(&a); // Увеличивает счётчик ссылок (НЕ глубокое копирование)
let c = Rc::clone(&a);
println!("Счётчик ссылок: {}", Rc::strong_count(&a)); // 3

// Все три указывают на один и тот же Vec. Когда уничтожается последний Rc,
// Vec освобождается.

// --- Arc<T>: несколько владельцев, потокобезопасно ---
// Использовать, когда нужно совместное владение между потоками
use std::sync::Arc;

let shared = Arc::new(String::from("общие данные"));
let handles: Vec<_> = (0..5).map(|_| {
    let shared = Arc::clone(&shared);
    std::thread::spawn(move || println!("{shared}"))
}).collect();
for h in handles { h.join().unwrap(); }
}

Weak-ссылки: разрыв циклов ссылок

Rc и Arc используют подсчёт ссылок, который не может освободить циклы (A → B → A). Weak<T> это невладеющий дескриптор, который не увеличивает сильный счётчик:

#![allow(unused)]
fn main() {
use std::rc::{Rc, Weak};
use std::cell::RefCell;

struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,   // НЕ удерживает родителя живым
    children: RefCell<Vec<Rc<Node>>>,
}

let parent = Rc::new(Node {
    value: 0, parent: RefCell::new(Weak::new()), children: RefCell::new(vec![]),
});
let child = Rc::new(Node {
    value: 1, parent: RefCell::new(Rc::downgrade(&parent)), children: RefCell::new(vec![]),
});
parent.children.borrow_mut().push(Rc::clone(&child));

// Доступ к родителю из потомка: возвращает Option<Rc<Node>>:
if let Some(p) = child.parent.borrow().upgrade() {
    println!("Значение родителя потомка: {}", p.value); // 0
}
// Когда `parent` уничтожается, strong_count → 0, память освобождается.
// Тогда `child.parent.upgrade()` вернёт `None`.
}

Практическое правило: используйте Rc/Arc для рёбер владения, а Weak для обратных ссылок и кэшей. Для потокобезопасного кода используйте Arc<T> вместе с sync::Weak<T>.

Cell и RefCell: внутренняя изменяемость

Иногда нужно изменять данные за разделяемой ссылкой (&). Rust предоставляет внутреннюю изменяемость с проверкой заимствований во время выполнения:

#![allow(unused)]
fn main() {
use std::cell::{Cell, RefCell};

// --- Cell<T>: внутренняя изменяемость через копирование ---
// Только для типов Copy (или для типов, которые можно заменять через swap/replace)
struct Counter {
    count: Cell<u32>,
}

impl Counter {
    fn new() -> Self { Counter { count: Cell::new(0) } }

    fn increment(&self) { // &self, а не &mut self!
        self.count.set(self.count.get() + 1);
    }

    fn value(&self) -> u32 { self.count.get() }
}

// --- RefCell<T>: проверка заимствований во время выполнения ---
// Паникует, если нарушить правила заимствования во время выполнения
struct Cache {
    data: RefCell<Vec<String>>,
}

impl Cache {
    fn new() -> Self { Cache { data: RefCell::new(Vec::new()) } }

    fn add(&self, item: String) { // &self: снаружи выглядит неизменяемым
        self.data.borrow_mut().push(item); // &mut, проверяемый во время выполнения
    }

    fn get_all(&self) -> Vec<String> {
        self.data.borrow().clone() // & , проверяемый во время выполнения
    }

    fn bad_example(&self) {
        let _guard1 = self.data.borrow();
        // let _guard2 = self.data.borrow_mut();
        // ❌ ПАНИКА во время выполнения: нельзя иметь &mut, пока существует &
    }
}
}

Cell или RefCell: Cell никогда не паникует (он копирует или меняет значения местами), но работает только с типами Copy или через swap()/replace(). RefCell работает с любым типом, но паникует при двойном изменяемом заимствовании. Ни один из них не является Sync: для многопоточного использования смотрите Mutex/RwLock.

Cow: клонирование при записи

Cow (Clone on Write) хранит либо заимствованное, либо владеющее значение. Он клонирует данные только тогда, когда нужна мутация:

use std::borrow::Cow;

// Не выделяет память, если изменение не нужно:
fn normalize(input: &str) -> Cow<'_, str> {
    if input.contains('\t') {
        // Выделяем память, только если табуляции нужно заменить
        Cow::Owned(input.replace('\t', "    "))
    } else {
        // Без выделения памяти: просто возвращаем ссылку
        Cow::Borrowed(input)
    }
}

fn main() {
    let clean = "no tabs here";
    let dirty = "tabs\there";

    let r1 = normalize(clean); // Cow::Borrowed: без выделения памяти
    let r2 = normalize(dirty); // Cow::Owned: выделена новая String

    println!("{r1}");
    println!("{r2}");
}

// Также полезно для параметров функции, которым МОЖЕТ понадобиться владение:
fn process(data: Cow<'_, [u8]>) {
    // Можно читать данные без копирования
    println!("Длина: {}", data.len());
    // Если нужна мутация, Cow сам клонирует:
    let mut owned = data.into_owned(); // Клонируем, только если Borrowed
    owned.push(0xFF);
}

Cow<'_, [u8]> для бинарных данных

Cow особенно полезен в API для работы с байтами, где данные могут требовать преобразования (вставка контрольной суммы, дополнение, экранирование), а могут и не требовать. Это позволяет не выделять Vec<u8> на частом быстром пути:

#![allow(unused)]
fn main() {
use std::borrow::Cow;

/// Дополняет кадр до минимальной длины, заимствуя данные, если дополнение не нужно.
fn pad_frame(frame: &[u8], min_len: usize) -> Cow<'_, [u8]> {
    if frame.len() >= min_len {
        Cow::Borrowed(frame)  // Уже достаточно длинный: без выделения памяти
    } else {
        let mut padded = frame.to_vec();
        padded.resize(min_len, 0x00);
        Cow::Owned(padded)    // Выделяем память, только когда нужно дополнение
    }
}

let short = pad_frame(&[0xDE, 0xAD], 8);    // Owned: дополнен до 8 байт
let long  = pad_frame(&[0; 64], 8);          // Borrowed: уже ≥ 8
}

Совет: сочетайте Cow<[u8]> с bytes::Bytes (гл. 11), когда нужно совместное использование с подсчётом ссылок для потенциально преобразованных буферов.

Какой указатель выбрать

УказательЧисло владельцевПотокобезопасныйИзменяемостьКогда использовать
Box<T>1✅ (если T: Send)Через &mutРазмещение в куче, трейт-объекты, рекурсивные типы
Rc<T>N❌Нет (оберните в Cell/RefCell)Совместное владение в одном потоке, графы и деревья
Arc<T>N✅Нет (оберните в Mutex/RwLock)Совместное владение между потоками
Cell<T>—❌.get() / .set()Внутренняя изменяемость для типов Copy
RefCell<T>—❌.borrow() / .borrow_mut()Внутренняя изменяемость для любого типа в одном потоке
Cow<'_, T>0 или 1✅ (если T: Send)Клонирование при записиИзбегать выделения памяти, когда данные часто не меняются

Pin и самоссылающиеся типы

Pin<P> не даёт значению переместиться в памяти. Это необходимо для самоссылающихся типов (структур, которые содержат указатель на собственные данные) и для Future, которые могут удерживать ссылки через точки .await.

use std::pin::Pin;
use std::marker::PhantomPinned;

// Самоссылающаяся структура (упрощённо):
struct SelfRef {
    data: String,
    ptr: *const String, // Указывает на `data` выше
    _pin: PhantomPinned, // Отказывается от Unpin: её нельзя переместить
}

impl SelfRef {
    fn new(s: &str) -> Pin<Box<Self>> {
        let val = SelfRef {
            data: s.to_string(),
            ptr: std::ptr::null(),
            _pin: PhantomPinned,
        };
        let mut boxed = Box::pin(val);

        // SAFETY: не перемещаем данные после установки указателя
        let self_ptr: *const String = &boxed.data;
        unsafe {
            let mut_ref = Pin::as_mut(&mut boxed);
            Pin::get_unchecked_mut(mut_ref).ptr = self_ptr;
        }
        boxed
    }

    fn data(&self) -> &str {
        &self.data
    }

    fn ptr_data(&self) -> &str {
        // SAFETY: ptr был установлен на указатель на self.data, пока структура закреплена
        unsafe { &*self.ptr }
    }
}

fn main() {
    let pinned = SelfRef::new("hello");
    assert_eq!(pinned.data(), pinned.ptr_data()); // Оба "hello"
    // std::mem::swap сделал бы ptr недействительным, но Pin этого не допускает
}

Ключевые понятия:

ПонятиеЗначение
Unpin (авто-трейт)«Перемещать этот тип безопасно». Большинство типов по умолчанию Unpin.
!Unpin / PhantomPinned«У меня есть внутренние указатели: не перемещайте меня».
Pin<&mut T>Изменяемая ссылка, которая гарантирует, что T не переместится
Pin<Box<T>>Владеющее значение, закреплённое в куче

Почему это важно для async: каждая async fn превращается в Future, который может удерживать ссылки через точки .await, то есть становится самоссылающимся. Асинхронный рантайм использует Pin<&mut Future>, чтобы гарантировать, что future не переместят после опроса (poll).

#![allow(unused)]
fn main() {
// Когда вы пишете:
async fn fetch(url: &str) -> String {
    let response = http_get(url).await; // ссылка удерживается через await
    response.text().await
}

// Компилятор генерирует структуру-автомат, которая является !Unpin,
// а рантайм закрепляет её перед вызовом Future::poll().
}

Когда задумываться о Pin: (1) при ручной реализации Future, (2) при написании асинхронных рантаймов или комбинаторов, (3) для любой структуры с самоссылающимися указателями. В обычном прикладном коде async/await прозрачно занимается закреплением. Подробнее см. в сопутствующем курсе Async Rust Training.

Альтернативы в виде крейтов: для самоссылающихся структур без ручного Pin рассмотрите ouroboros или self_cell. Они генерируют безопасные обёртки с правильным закреплением и семантикой drop.

Проекции pin: структурное закрепление

Когда у вас есть Pin<&mut MyStruct>, часто нужно обращаться к отдельным полям. Проекция pin это паттерн безопасного перехода от Pin<&mut Struct> к Pin<&mut Field> (для закреплённых полей) или к &mut Field (для незакреплённых полей).

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

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::marker::PhantomPinned;

struct MyFuture {
    data: String,              // Обычное поле: безопасно перемещать
    state: InternalState,      // Самоссылающееся: должно оставаться закреплённым
    _pin: PhantomPinned,
}

enum InternalState {
    Waiting { ptr: *const String }, // Указывает на `data`: самоссылка
    Done,
}

// Дано `Pin<&mut MyFuture>`, как получить доступ к `data` и `state`?
// НЕЛЬЗЯ просто написать `pinned.data`: компилятор не даст
// получить &mut к полю закреплённого значения без unsafe.
}

Ручная проекция pin (unsafe)

#![allow(unused)]
fn main() {
impl MyFuture {
    // Проекция на `data`: это поле структурно не закреплено (безопасно перемещать)
    fn data(self: Pin<&mut Self>) -> &mut String {
        // SAFETY: `data` структурно не закреплено. Перемещение только `data`
        // не перемещает всю структуру, поэтому гарантия Pin сохраняется.
        unsafe { &mut self.get_unchecked_mut().data }
    }

    // Проекция на `state`: это поле структурно закреплено
    fn state(self: Pin<&mut Self>) -> Pin<&mut InternalState> {
        // SAFETY: `state` структурно закреплено: мы поддерживаем инвариант
        // закрепления, возвращая Pin<&mut InternalState>.
        unsafe { Pin::new_unchecked(&mut self.get_unchecked_mut().state) }
    }
}
}

Правила структурного закрепления: поле «структурно закреплено», если:

  1. Перемещение или обмен только этого поля может нарушить самоссылку
  2. Реализация Drop для структуры не должна перемещать это поле
  3. Структура должна быть !Unpin (обеспечивается PhantomPinned или полем !Unpin)

pin-project: безопасные проекции pin (без unsafe)

Крейт pin-project генерирует корректные проекции на этапе компиляции, избавляя от необходимости ручного unsafe:

#![allow(unused)]
fn main() {
use pin_project::pin_project;
use std::pin::Pin;
use std::future::Future;
use std::task::{Context, Poll};

#[pin_project]                   // <-- Генерирует методы проекции
struct TimedFuture<F: Future> {
    #[pin]                       // <-- Структурно закреплено (это Future)
    inner: F,
    started_at: std::time::Instant, // НЕ закреплено: обычные данные
}

impl<F: Future> Future for TimedFuture<F> {
    type Output = (F::Output, std::time::Duration);

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        let this = self.project();  // Безопасно! Генерируется pin_project
        //   this.inner   : Pin<&mut F>              — закреплённое поле
        //   this.started_at : &mut std::time::Instant — незакреплённое поле

        match this.inner.poll(cx) {
            Poll::Ready(output) => {
                let elapsed = this.started_at.elapsed();
                Poll::Ready((output, elapsed))
            }
            Poll::Pending => Poll::Pending,
        }
    }
}
}

pin-project и ручная проекция

АспектВручную (unsafe)pin-project
БезопасностьВы доказываете инвариантыПроверяется компилятором
Шаблонный кодМало (но легко ошибиться)Нет: derive-макрос
Взаимодействие с DropНельзя перемещать закреплённые поляОбеспечивается через #[pinned_drop]
Стоимость на этапе компиляцииНетРаскрытие процедурного макроса
ПрименениеПримитивы, no_stdПрикладной код и библиотеки

#[pinned_drop]: Drop для закреплённых типов

Если у типа есть поля с #[pin], pin-project требует #[pinned_drop] вместо обычной реализации Drop, чтобы случайно не переместить закреплённые поля:

#![allow(unused)]
fn main() {
use pin_project::{pin_project, pinned_drop};
use std::pin::Pin;

#[pin_project(PinnedDrop)]
struct Connection<F> {
    #[pin]
    future: F,
    buffer: Vec<u8>,  // Не закреплено: можно переместить в drop
}

#[pinned_drop]
impl<F> PinnedDrop for Connection<F> {
    fn drop(self: Pin<&mut Self>) {
        let this = self.project();
        // `this.future` имеет тип Pin<&mut F>: его нельзя переместить, только уничтожить на месте
        // `this.buffer` имеет тип &mut Vec<u8>: его можно опустошить, очистить и т. д.
        this.buffer.clear();
        println!("Соединение уничтожено, буфер очищен");
    }
}
}

Когда проекции pin важны на практике

Примечание: диаграмма ниже использует синтаксис Mermaid. Она отображается на GitHub и в инструментах с поддержкой Mermaid (mdBook с плагином mermaid, VS Code с расширением Mermaid). В простых просмотрщиках Markdown вы увидите исходный код.

graph TD
    A["Вы реализуете Future вручную?"] -->|Да| B["Удерживает ли future ссылки<br/>через точки .await?"]
    A -->|Нет| C["async/await сам занимается Pin<br/>✅ Проекции не нужны"]
    B -->|Да| D["Используйте #[pin_project] на<br/>вашей структуре future"]
    B -->|Нет| E["Ваш future является Unpin<br/>✅ Проекции не нужны"]
    D --> F["Помечайте futures/streams как #[pin]<br/>Оставляйте поля данных незакреплёнными"]

    style C fill:#91e5a3,color:#000
    style E fill:#91e5a3,color:#000
    style D fill:#ffa07a,color:#000
    style F fill:#ffa07a,color:#000

Практическое правило: если вы оборачиваете другой Future или Stream, используйте pin-project. Если вы пишете прикладной код с async/await, проекции pin вам напрямую никогда не понадобятся. Подробнее о комбинаторах async с проекциями pin см. в сопутствующем курсе Async Rust Training.

Порядок drop и ManuallyDrop

Порядок drop в Rust детерминирован, но у него есть правила, которые стоит знать:

Правила порядка drop

struct Label(&'static str);

impl Drop for Label {
    fn drop(&mut self) { println!("Уничтожается {}", self.0); }
}

fn main() {
    let a = Label("first");   // Объявлена первой
    let b = Label("second");  // Объявлена второй
    let c = Label("third");   // Объявлена третьей
}
// Вывод:
//   Уничтожается third    ← локальные переменные уничтожаются в ОБРАТНОМ порядке объявления
//   Уничтожается second
//   Уничтожается first

Три правила:

ЧтоПорядок dropОбоснование
Локальные переменныеВ обратном порядке объявленияБолее поздние переменные могут ссылаться на более ранние
Поля структурыВ порядке объявления (сверху вниз)Совпадает с порядком создания (стабильно с Rust 1.0, гарантируется RFC 1857)
Элементы кортежаВ порядке объявления (слева направо)(a, b, c): сначала a, затем b, затем c
#![allow(unused)]
fn main() {
struct Server {
    listener: Label,  // Уничтожается 1-м
    handler: Label,   // Уничтожается 2-м
    logger: Label,    // Уничтожается 3-м
}
// Поля уничтожаются сверху вниз (в порядке объявления).
// Это важно, когда поля ссылаются друг на друга или держат ресурсы.
}

Практическое влияние: если в вашей структуре есть JoinHandle и Sender, порядок полей определяет, что уничтожается первым. Если поток читает из канала, сначала уничтожьте Sender (закройте канал), чтобы поток завершился, а потом дождитесь JoinHandle. Поставьте Sender выше JoinHandle в структуре.

ManuallyDrop<T>: подавление автоматического drop

ManuallyDrop<T> оборачивает значение и не даёт его деструктору выполниться автоматически. Ответственность за уничтожение (или намеренную утечку) лежит на вас:

#![allow(unused)]
fn main() {
use std::mem::ManuallyDrop;

// Случай 1: предотвращаем двойное освобождение в unsafe-коде
struct TwoPhaseBuffer {
    // Vec нужно уничтожить самим, чтобы контролировать момент
    data: ManuallyDrop<Vec<u8>>,
    committed: bool,
}

impl TwoPhaseBuffer {
    fn new(capacity: usize) -> Self {
        TwoPhaseBuffer {
            data: ManuallyDrop::new(Vec::with_capacity(capacity)),
            committed: false,
        }
    }

    fn write(&mut self, bytes: &[u8]) {
        self.data.extend_from_slice(bytes);
    }

    fn commit(&mut self) {
        self.committed = true;
        println!("Зафиксировано байт: {}", self.data.len());
    }
}

impl Drop for TwoPhaseBuffer {
    fn drop(&mut self) {
        if !self.committed {
            println!("Откат: уничтожаем незафиксированные данные");
        }
        // SAFETY: data здесь всегда корректен; уничтожаем его только один раз.
        unsafe { ManuallyDrop::drop(&mut self.data); }
    }
}
}
#![allow(unused)]
fn main() {
// Случай 2: намеренная утечка (например, глобальные синглтоны)
fn leaked_string() -> &'static str {
    // Box::leak() — идиоматичный способ создать ссылку &'static:
    let s = String::from("живёт вечно");
    Box::leak(s.into_boxed_str())
    // ⚠️ Это контролируемая утечка памяти. Выделение String в куче
    // никогда не освобождается. Используйте только для долгоживущих синглтонов.
}

// Альтернатива через ManuallyDrop (требует unsafe):
// ⚠️ Предпочитайте Box::leak() выше: это показано только для иллюстрации
// семантики ManuallyDrop (подавление Drop при сохранении данных в куче).
fn leaked_string_manual() -> &'static str {
    use std::mem::ManuallyDrop;
    let md = ManuallyDrop::new(String::from("живёт вечно"));
    // SAFETY: ManuallyDrop предотвращает освобождение; данные в куче живут
    // вечно, поэтому ссылка 'static корректна.
    unsafe { &*(md.as_str() as *const str) }
}
}
#![allow(unused)]
fn main() {
// Случай 3: поля union (в каждый момент действителен только один вариант)
use std::mem::ManuallyDrop;

union IntOrString {
    i: u64,
    s: ManuallyDrop<String>,
    // String имеет реализацию Drop, поэтому он ДОЛЖЕН быть обёрнут в ManuallyDrop
    // внутри union: компилятор не может знать, какое поле активно.
}

// Автоматического Drop нет: код, который создаёт IntOrString, тоже должен
// заниматься очисткой. Если активен вариант String, вызовите:
//   unsafe { ManuallyDrop::drop(&mut value.s); }
// Без реализации Drop union просто утекает (без UB, только утечка).
}

ManuallyDrop против mem::forget:

ManuallyDrop<T>mem::forget(value)
КогдаОборачивание при созданииПотребление позже
Доступ к внутреннему&*md / &mut *mdЗначение уже недоступно
Уничтожить позжеManuallyDrop::drop(&mut md)Невозможно
СценарийТонкое управление жизненным циклом«Выстрелил и забыл»: утечка

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

Ключевые выводы: умные указатели

  • Box для единоличного владения в куче; Rc/Arc для совместного владения (в одном потоке / в нескольких)
  • Cell/RefCell дают внутреннюю изменяемость; RefCell паникует при нарушении правил во время выполнения
  • Cow избегает выделения памяти на частом пути; Pin запрещает перемещение самоссылающихся типов
  • Порядок drop: поля уничтожаются в порядке объявления (RFC 1857); локальные переменные в обратном порядке объявления

См. также: гл. 6 — Конкурентность о паттернах Arc + Mutex. гл. 4 — PhantomData о PhantomData с умными указателями.

graph TD
    Box["Box&lt;T&gt;<br>Единственный владелец, куча"] --> Heap["Размещение в куче"]
    Rc["Rc&lt;T&gt;<br>Общий, один поток"] --> Heap
    Arc["Arc&lt;T&gt;<br>Общий, несколько потоков"] --> Heap

    Rc --> Weak1["Weak&lt;T&gt;<br>Невладеющий"]
    Arc --> Weak2["Weak&lt;T&gt;<br>Невладеющий"]

    Cell["Cell&lt;T&gt;<br>Внутр. изменяемость для Copy"] --> Stack["Стек / внутри значения"]
    RefCell["RefCell&lt;T&gt;<br>Проверка заимствований во время выполнения"] --> Stack
    Cow["Cow&lt;T&gt;<br>Клонирование при записи"] --> Stack

    style Box fill:#d4efdf,stroke:#27ae60,color:#000
    style Rc fill:#e8f4f8,stroke:#2980b9,color:#000
    style Arc fill:#e8f4f8,stroke:#2980b9,color:#000
    style Weak1 fill:#fef9e7,stroke:#f1c40f,color:#000
    style Weak2 fill:#fef9e7,stroke:#f1c40f,color:#000
    style Cell fill:#fdebd0,stroke:#e67e22,color:#000
    style RefCell fill:#fdebd0,stroke:#e67e22,color:#000
    style Cow fill:#fdebd0,stroke:#e67e22,color:#000
    style Heap fill:#f5f5f5,stroke:#999,color:#000
    style Stack fill:#f5f5f5,stroke:#999,color:#000

Упражнение: граф с подсчётом ссылок ★★ (~30 минут)

Постройте направленный граф с помощью Rc<RefCell<Node>>, где каждый узел имеет имя и список потомков. Создайте цикл (A → B → C → A), используя Weak для обратного ребра. Убедитесь, что утечки памяти нет, с помощью Rc::strong_count.

🔑 Решение
use std::cell::RefCell;
use std::rc::{Rc, Weak};

struct Node {
    name: String,
    children: Vec<Rc<RefCell<Node>>>,
    back_ref: Option<Weak<RefCell<Node>>>,
}

impl Node {
    fn new(name: &str) -> Rc<RefCell<Self>> {
        Rc::new(RefCell::new(Node {
            name: name.to_string(),
            children: Vec::new(),
            back_ref: None,
        }))
    }
}

impl Drop for Node {
    fn drop(&mut self) {
        println!("Уничтожается {}", self.name);
    }
}

fn main() {
    let a = Node::new("A");
    let b = Node::new("B");
    let c = Node::new("C");

    // A → B → C, при этом C ссылается назад на A через Weak
    a.borrow_mut().children.push(Rc::clone(&b));
    b.borrow_mut().children.push(Rc::clone(&c));
    c.borrow_mut().back_ref = Some(Rc::downgrade(&a)); // Weak-ссылка!

    println!("Сильных ссылок у A: {}", Rc::strong_count(&a)); // 1 (только привязка `a`)
    println!("Сильных ссылок у B: {}", Rc::strong_count(&b)); // 2 (b + потомок A)
    println!("Сильных ссылок у C: {}", Rc::strong_count(&c)); // 2 (c + потомок B)

    // Поднимаем weak-ссылку, чтобы проверить, что она работает:
    let c_ref = c.borrow();
    if let Some(back) = &c_ref.back_ref {
        if let Some(a_ref) = back.upgrade() {
            println!("C ссылается назад на: {}", a_ref.borrow().name);
        }
    }
    // Когда a, b, c выходят из области видимости, все узлы уничтожаются (утечки цикла нет!)
}

10. Паттерны обработки ошибок 🟢

Что вы узнаете:

  • Когда использовать thiserror (библиотеки), а когда anyhow (приложения)
  • Цепочки преобразования ошибок через #[from] и обёртки .context()
  • Как оператор ? раскрывается и как работает в main()
  • Когда паниковать, а когда возвращать ошибки, и catch_unwind для границ FFI

thiserror и anyhow: библиотеки и приложения

Обработка ошибок в Rust строится вокруг типа Result<T, E>. Доминируют два крейта:

// --- thiserror: ДЛЯ БИБЛИОТЕК ---
// Генерирует реализации Display, Error и From через derive-макросы
use thiserror::Error;

#[derive(Error, Debug)]
pub enum DatabaseError {
    #[error("ошибка подключения: {0}")]
    ConnectionFailed(String),

    #[error("ошибка запроса: {source}")]
    QueryError {
        #[source]
        source: sqlx::Error,
    },

    #[error("запись не найдена: таблица={table}, id={id}")]
    NotFound { table: String, id: u64 },

    #[error(transparent)] // Делегировать Display внутренней ошибке
    Io(#[from] std::io::Error), // Автоматически генерирует From<io::Error>
}

// --- anyhow: ДЛЯ ПРИЛОЖЕНИЙ ---
// Динамический тип ошибки: удобен в верхнеуровневом коде, где нужно просто пробросить ошибку дальше
use anyhow::{Context, Result, bail, ensure};

fn read_config(path: &str) -> Result<Config> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("не удалось прочитать конфигурацию из {path}"))?;

    let config: Config = serde_json::from_str(&content)
        .context("не удалось разобрать JSON конфигурации")?;

    ensure!(config.port > 0, "порт должен быть положительным, получено {}", config.port);

    Ok(config)
}

fn main() -> Result<()> {
    let config = read_config("server.toml")?;

    if config.name.is_empty() {
        bail!("имя сервера не может быть пустым"); // Немедленно возвращаем Err
    }

    Ok(())
}

Когда что использовать:

thiserroranyhow
Где использоватьБиблиотеки, общие крейтыПриложения, исполняемые файлы
Типы ошибокКонкретные перечисления: вызывающий код может сопоставлять их с образцомanyhow::Error: непрозрачный тип
УсилияОпределить собственное перечисление ошибокПросто использовать Result<T>
Приведение типа (downcasting)Не нужно: сопоставление с образцомerror.downcast_ref::<MyError>()

Цепочки преобразования ошибок (#[from])

use thiserror::Error;

#[derive(Error, Debug)]
enum AppError {
    #[error("ошибка ввода-вывода: {0}")]
    Io(#[from] std::io::Error),

    #[error("ошибка JSON: {0}")]
    Json(#[from] serde_json::Error),

    #[error("ошибка HTTP: {0}")]
    Http(#[from] reqwest::Error),
}

// Теперь ? автоматически выполняет преобразование:
fn fetch_and_parse(url: &str) -> Result<Config, AppError> {
    let body = reqwest::blocking::get(url)?.text()?;  // reqwest::Error → AppError::Http
    let config: Config = serde_json::from_str(&body)?; // serde_json::Error → AppError::Json
    Ok(config)
}

Контекст и оборачивание ошибок

Добавляйте понятный контекст к ошибкам, не теряя исходную:

use anyhow::{Context, Result};

fn process_file(path: &str) -> Result<Data> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("не удалось прочитать {path}"))?;

    let data = parse_content(&content)
        .with_context(|| format!("не удалось разобрать {path}"))?;

    validate(&data)
        .context("ошибка валидации")?;

    Ok(data)
}

// Вывод ошибки:
// Error: ошибка валидации
//
// Caused by:
//    0: не удалось разобрать config.json
//    1: expected ',' at line 5 column 12

Оператор ? подробнее

? это синтаксический сахар для match, преобразования From и раннего возврата:

#![allow(unused)]
fn main() {
// Это:
let value = operation()?;

// Раскрывается в:
let value = match operation() {
    Ok(v) => v,
    Err(e) => return Err(From::from(e)),
    //                  ^^^^^^^^^^^^^^
    //                  Автоматическое преобразование через трейт From
};
}

? также работает с Option (в функциях, которые возвращают Option):

#![allow(unused)]
fn main() {
fn find_user_email(users: &[User], name: &str) -> Option<String> {
    let user = users.iter().find(|u| u.name == name)?; // Возвращает None, если не найден
    let email = user.email.as_ref()?; // Возвращает None, если email равен None
    Some(email.to_uppercase())
}
}

Паника, catch_unwind и когда прерывать работу

#![allow(unused)]
fn main() {
// Паника: для ОШИБОК В КОДЕ, а не для ожидаемых ошибок
fn get_element(data: &[i32], index: usize) -> &i32 {
    // Если здесь случится паника, это ошибка программиста (баг).
    // Не «обрабатывайте» её, а исправьте вызывающий код.
    &data[index]
}

// catch_unwind: для границ (FFI, пулы потоков)
use std::panic;

let result = panic::catch_unwind(|| {
    // Безопасно запускаем код, который может паниковать
    risky_operation()
});

match result {
    Ok(value) => println!("Успех: {value:?}"),
    Err(_) => eprintln!("Операция завершилась паникой, продолжаем работу"),
}

// Когда что использовать:
// - Result<T, E> → ожидаемые сбои (файл не найден, таймаут сети)
// - panic!()     → ошибки в программе (выход за границы индекса, нарушен инвариант)
// - process::abort() → невосстановимое состояние (нарушение безопасности, повреждённые данные)
}

Сравнение с C++: Result<T, E> заменяет исключения для ожидаемых ошибок. panic!() похож на assert() или std::terminate(): он предназначен для багов, а не для управления ходом программы. Оператор ? делает распространение ошибок таким же удобным, как исключения, но без непредсказуемого потока управления.

Ключевые выводы: обработка ошибок

  • Библиотеки: thiserror для структурированных перечислений ошибок; приложения: anyhow для удобного распространения ошибок
  • #[from] автоматически генерирует реализации From; .context() добавляет понятные обёртки
  • ? раскрывается в From::from() и ранний возврат; работает в main(), возвращающей Result

См. также: гл. 15 — Дизайн API о паттернах «parse, don’t validate». гл. 11 — Сериализация об обработке ошибок в serde.

flowchart LR
    A["std::io::Error"] -->|"#[from]"| B["AppError::Io"]
    C["serde_json::Error"] -->|"#[from]"| D["AppError::Json"]
    E["Пользовательская проверка"] -->|"вручную"| F["AppError::Validation"]

    B --> G["Оператор ?"]
    D --> G
    F --> G
    G --> H["Result&lt;T, AppError&gt;"]

    style A fill:#e8f4f8,stroke:#2980b9,color:#000
    style C fill:#e8f4f8,stroke:#2980b9,color:#000
    style E fill:#e8f4f8,stroke:#2980b9,color:#000
    style B fill:#fdebd0,stroke:#e67e22,color:#000
    style D fill:#fdebd0,stroke:#e67e22,color:#000
    style F fill:#fdebd0,stroke:#e67e22,color:#000
    style G fill:#fef9e7,stroke:#f1c40f,color:#000
    style H fill:#d4efdf,stroke:#27ae60,color:#000

Упражнение: иерархия ошибок на thiserror ★★ (~30 минут)

Спроектируйте иерархию типов ошибок для приложения обработки файлов, которое может завершиться ошибкой при вводе-выводе, разборе (JSON и CSV) или валидации. Используйте thiserror и продемонстрируйте распространение ошибок через ?.

🔑 Решение
use thiserror::Error;

#[derive(Error, Debug)]
pub enum AppError {
    #[error("ошибка ввода-вывода: {0}")]
    Io(#[from] std::io::Error),

    #[error("ошибка разбора JSON: {0}")]
    Json(#[from] serde_json::Error),

    #[error("ошибка CSV в строке {line}: {message}")]
    Csv { line: usize, message: String },

    #[error("ошибка валидации: {field} — {reason}")]
    Validation { field: String, reason: String },
}

fn read_file(path: &str) -> Result<String, AppError> {
    Ok(std::fs::read_to_string(path)?) // io::Error → AppError::Io через #[from]
}

fn parse_json(content: &str) -> Result<serde_json::Value, AppError> {
    Ok(serde_json::from_str(content)?) // serde_json::Error → AppError::Json
}

fn validate_name(value: &serde_json::Value) -> Result<String, AppError> {
    let name = value.get("name")
        .and_then(|v| v.as_str())
        .ok_or_else(|| AppError::Validation {
            field: "name".into(),
            reason: "должно быть строкой, а не null".into(),
        })?;

    if name.is_empty() {
        return Err(AppError::Validation {
            field: "name".into(),
            reason: "не должно быть пустым".into(),
        });
    }

    Ok(name.to_string())
}

fn process_file(path: &str) -> Result<String, AppError> {
    let content = read_file(path)?;
    let json = parse_json(&content)?;
    let name = validate_name(&json)?;
    Ok(name)
}

fn main() {
    match process_file("config.json") {
        Ok(name) => println!("Имя: {name}"),
        Err(e) => eprintln!("Ошибка: {e}"),
    }
}

11. Сериализация, zero-copy и бинарные данные 🟡

Что вы узнаете:

  • Основы serde: derive-макросы, атрибуты и представления перечислений
  • Десериализацию без копирования (zero-copy) для высоконагруженных сценариев чтения
  • Экосистему форматов serde (JSON, TOML, bincode, MessagePack)
  • Работу с бинарными данными через repr(C), zerocopy и bytes::Bytes

Основы serde

serde (SERialize/DEserialize) это универсальная система сериализации для Rust. Она отделяет модель данных (ваши структуры) от формата (JSON, TOML, бинарный):

use serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize)]
struct ServerConfig {
    name: String,
    port: u16,
    #[serde(default)]                    // Использовать Default::default(), если поле отсутствует
    max_connections: usize,
    #[serde(skip_serializing_if = "Option::is_none")]
    tls_cert_path: Option<String>,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Десериализация из JSON:
    let json_input = r#"{
        "name": "hw-diag",
        "port": 8080
    }"#;
    let config: ServerConfig = serde_json::from_str(json_input)?;
    println!("{config:?}");
    // ServerConfig { name: "hw-diag", port: 8080, max_connections: 0, tls_cert_path: None }

    // Сериализация в JSON:
    let output = serde_json::to_string_pretty(&config)?;
    println!("{output}");

    // Та же структура, другой формат, без изменений в коде:
    let toml_input = r#"
        name = "hw-diag"
        port = 8080
    "#;
    let config: ServerConfig = toml::from_str(toml_input)?;
    println!("{config:?}");

    Ok(())
}

Ключевая мысль: ваша структура один раз получает derive Serialize и Deserialize. После этого она работает с любым совместимым с serde форматом: JSON, TOML, YAML, bincode, MessagePack, CBOR, postcard и десятками других.

Распространённые атрибуты serde

serde даёт тонкое управление сериализацией через атрибуты полей и контейнеров:

use serde::{Serialize, Deserialize};

// --- Атрибуты контейнера (на структуре или перечислении) ---
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]       // Соглашение JSON: field_name → fieldName
#[serde(deny_unknown_fields)]            // Отвергать лишние ключи: строгий разбор
struct DiagResult {
    test_name: String,                   // Сериализуется как "testName"
    pass_count: u32,                     // Сериализуется как "passCount"
    fail_count: u32,                     // Сериализуется как "failCount"
}

// --- Атрибуты полей ---
#[derive(Serialize, Deserialize)]
struct Sensor {
    #[serde(rename = "sensor_id")]       // Переопределить имя поля при сериализации
    id: u64,

    #[serde(default)]                    // Использовать Default, если поля нет во входных данных
    enabled: bool,

    #[serde(default = "default_threshold")]
    threshold: f64,

    #[serde(skip)]                       // Никогда не сериализовать и не десериализовать
    cached_value: Option<f64>,

    #[serde(skip_serializing_if = "Vec::is_empty")]
    tags: Vec<String>,

    #[serde(flatten)]                    // Встроить поля вложенной структуры
    metadata: Metadata,

    #[serde(with = "hex_bytes")]         // Пользовательский модуль сериализации/десериализации
    raw_data: Vec<u8>,
}

fn default_threshold() -> f64 { 1.0 }

#[derive(Serialize, Deserialize)]
struct Metadata {
    vendor: String,
    model: String,
}
// С #[serde(flatten)] JSON выглядит так:
// { "sensor_id": 1, "vendor": "Intel", "model": "X200", ... }
// А НЕ так: { "sensor_id": 1, "metadata": { "vendor": "Intel", ... } }

Шпаргалка по самым используемым атрибутам:

АтрибутУровеньЭффект
rename_all = "camelCase"КонтейнерПереименовать все поля в camelCase, snake_case или SCREAMING_SNAKE_CASE
deny_unknown_fieldsКонтейнерОшибка при неожиданных ключах (строгий режим)
defaultПолеИспользовать Default::default(), если поле отсутствует
rename = "..."ПолеПользовательское имя при сериализации
skipПолеПолностью исключить из сериализации и десериализации
skip_serializing_if = "fn"ПолеУсловно исключить (например, Option::is_none)
flattenПолеВстроить поля вложенной структуры
with = "module"ПолеИспользовать пользовательские функции сериализации и десериализации
alias = "..."ПолеПринимать альтернативные имена при десериализации
deserialize_with = "fn"ПолеТолько пользовательская функция десериализации
untaggedПеречислениеПробовать каждый вариант по порядку (в выводе нет дискриминанта)

Представления перечислений

serde предоставляет четыре представления перечислений для форматов вроде JSON:

use serde::{Serialize, Deserialize};

// 1. Внешне помеченное (по умолчанию):
#[derive(Serialize, Deserialize)]
enum Command {
    Reboot,
    RunDiag { test_name: String, timeout_secs: u64 },
    SetFanSpeed(u8),
}
// "Reboot"                                          → Command::Reboot
// {"RunDiag": {"test_name": "gpu", "timeout_secs": 60}}  → Command::RunDiag { ... }

// 2. Внутренне помеченное: #[serde(tag = "type")]:
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Event {
    Start { timestamp: u64 },
    Error { code: i32, message: String },
    End   { timestamp: u64, success: bool },
}
// {"type": "Start", "timestamp": 1706000000}
// {"type": "Error", "code": 42, "message": "timeout"}

// 3. Смежно помеченное: #[serde(tag = "t", content = "c")]:
#[derive(Serialize, Deserialize)]
#[serde(tag = "t", content = "c")]
enum Payload {
    Text(String),
    Binary(Vec<u8>),
}
// {"t": "Text", "c": "hello"}
// {"t": "Binary", "c": [0, 1, 2]}

// 4. Без метки: #[serde(untagged)]:
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum StringOrNumber {
    Str(String),
    Num(f64),
}
// "hello" → StringOrNumber::Str("hello")
// 42.0    → StringOrNumber::Num(42.0)
// ⚠️ Проверяются ПО ПОРЯДКУ: побеждает первый подходящий вариант

Какое представление выбрать: для большинства JSON API используйте внутренне помеченное (tag = "type"): оно самое читаемое и соответствует соглашениям в Go, Python и TypeScript. Без метки используйте только для «объединённых» типов, где одна лишь форма однозначно определяет вариант.

Десериализация без копирования

serde умеет десериализовать без выделения памяти под новые строки: заимствовать данные прямо из входного буфера. Это ключ к высокопроизводительному разбору:

use serde::Deserialize;

// --- Владеющая (с выделением памяти) ---
// Каждое строковое поле копирует байты из входных данных в новую выделенную память в куче.
#[derive(Deserialize)]
struct OwnedRecord {
    name: String,           // Выделяет новую String
    value: String,          // Выделяет ещё одну String
}

// --- Без копирования (заимствование) ---
// Поля &'de str заимствуют данные напрямую из входа: НИКАКИХ выделений памяти.
#[derive(Deserialize)]
struct BorrowedRecord<'a> {
    name: &'a str,          // Указывает во входной буфер
    value: &'a str,         // Указывает во входной буфер
}

fn main() {
    let input = r#"{"name": "cpu_temp", "value": "72.5"}"#;

    // Владеющая: выделяет два объекта String
    let owned: OwnedRecord = serde_json::from_str(input).unwrap();

    // Без копирования: `name` и `value` указывают в `input`, без выделения памяти
    let borrowed: BorrowedRecord = serde_json::from_str(input).unwrap();

    // Результат привязан ко времени жизни: borrowed не может пережить input
    println!("{}: {}", borrowed.name, borrowed.value);
}

Понимание времени жизни:

// Deserialize<'de>: структура может заимствовать данные с временем жизни 'de:
//   struct BorrowedRecord<'a> where 'a == 'de
//   Работает, только если входной буфер живёт достаточно долго

// DeserializeOwned: структура владеет всеми своими данными, без заимствования:
//   trait DeserializeOwned: for<'de> Deserialize<'de> {}
//   Работает с любым временем жизни входа (структура независима от него)

use serde::de::DeserializeOwned;

// Эта функция требует владеющих типов: вход может быть временным
fn parse_owned<T: DeserializeOwned>(input: &str) -> T {
    serde_json::from_str(input).unwrap()
}

// Эта функция допускает заимствование: эффективнее, но ограничивает времена жизни
fn parse_borrowed<'a, T: Deserialize<'a>>(input: &'a str) -> T {
    serde_json::from_str(input).unwrap()
}

Когда использовать zero-copy:

  • Разбор больших файлов, из которых нужны лишь несколько полей
  • Конвейеры с высокой пропускной способностью (сетевые пакеты, строки журнала)
  • Когда входной буфер уже живёт достаточно долго (например, memory-mapped файл)

Когда НЕ использовать zero-copy:

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

Практический совет: Cow<'a, str> даёт лучшее из обоих миров: заимствует, когда возможно, и выделяет память, когда необходимо (например, когда нужно раскрыть экранированные последовательности JSON). serde поддерживает Cow напрямую.

Экосистема форматов

ФорматКрейтЧитается человекомРазмерСкоростьСценарий использования
JSONserde_json✅БольшойХорошаяКонфигурационные файлы, REST API, журналирование
TOMLtoml✅СреднийХорошаяКонфигурационные файлы (в стиле Cargo.toml)
YAMLserde_yaml✅СреднийХорошаяКонфигурационные файлы (сложная вложенность)
bincodebincode❌МаленькийБыстраяIPC, кэши, обмен между программами на Rust
postcardpostcard❌КрошечныйОчень быстраяВстраиваемые системы, no_std
MessagePackrmp-serde❌МаленькийБыстраяМежъязыковой бинарный протокол
CBORciborium❌МаленькийБыстраяIoT, ограниченные окружения
#![allow(unused)]
fn main() {
// Одна структура, множество форматов: сила serde

#[derive(serde::Serialize, serde::Deserialize, Debug)]
struct DiagConfig {
    name: String,
    tests: Vec<String>,
    timeout_secs: u64,
}

let config = DiagConfig {
    name: "accel_diag".into(),
    tests: vec!["memory".into(), "compute".into()],
    timeout_secs: 300,
};

// JSON:   {"name":"accel_diag","tests":["memory","compute"],"timeout_secs":300}
let json = serde_json::to_string(&config).unwrap();       // 67 байт

// bincode: компактный бинарный формат, ~40 байт, без имён полей
let bin = bincode::serialize(&config).unwrap();            // Заметно меньше

// postcard: ещё меньше, кодирование varint, отлично для встраиваемых систем
// let post = postcard::to_allocvec(&config).unwrap();
}

Выбирайте формат:

  • Конфигурационные файлы, которые редактируют люди: TOML или JSON
  • IPC и кэширование между программами на Rust: bincode (быстро, компактно, но только для Rust)
  • Межъязыковой бинарный формат: MessagePack или CBOR
  • Встраиваемые системы и no_std: postcard

Бинарные данные и repr(C)

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

#![allow(unused)]
fn main() {
// --- #[repr(C)]: предсказуемая раскладка в памяти ---
// Гарантирует, что поля размещаются в порядке объявления по правилам выравнивания C.
// Необходимо для соответствия раскладке регистров оборудования и заголовков протоколов.

#[repr(C)]
#[derive(Debug, Clone, Copy)]
struct IpmiHeader {
    rs_addr: u8,
    net_fn_lun: u8,
    checksum: u8,
    rq_addr: u8,
    rq_seq_lun: u8,
    cmd: u8,
}

// --- Безопасный разбор бинарных данных вручную ---
impl IpmiHeader {
    fn from_bytes(data: &[u8]) -> Option<Self> {
        if data.len() < size_of::<Self>() {
            return None;
        }
        Some(IpmiHeader {
            rs_addr:     data[0],
            net_fn_lun:  data[1],
            checksum:    data[2],
            rq_addr:     data[3],
            rq_seq_lun:  data[4],
            cmd:         data[5],
        })
    }

    fn net_fn(&self) -> u8 { self.net_fn_lun >> 2 }
    fn lun(&self)    -> u8 { self.net_fn_lun & 0x03 }
}

// --- Разбор с учётом порядка байтов (endianness) ---
fn read_u16_le(data: &[u8], offset: usize) -> u16 {
    u16::from_le_bytes([data[offset], data[offset + 1]])
}

fn read_u32_be(data: &[u8], offset: usize) -> u32 {
    u32::from_be_bytes([
        data[offset], data[offset + 1],
        data[offset + 2], data[offset + 3],
    ])
}

// --- #[repr(C, packed)]: убираем выравнивание (alignment = 1) ---
#[repr(C, packed)]
#[derive(Debug, Clone, Copy)]
struct PcieCapabilityHeader {
    cap_id: u8,        // Идентификатор capability
    next_cap: u8,      // Указатель на следующую capability
    cap_reg: u16,      // Регистр, специфичный для capability
}
// ⚠️ Упакованные структуры: взятие &field создаёт невыровненную ссылку — UB.
// Всегда копируйте поля: let id = header.cap_id;  // OK (Copy)
// Никогда не делайте так: let r = &header.cap_reg;               // UB, если невыровнено
}

zerocopy и bytemuck: безопасная трансмутация

Вместо unsafe transmute используйте крейты, которые проверяют безопасность раскладки:

#![allow(unused)]
fn main() {
// --- zerocopy: преобразования без копирования с проверкой раскладки на этапе компиляции ---
// Cargo.toml: zerocopy = { version = "0.8", features = ["derive"] }

use zerocopy::{FromBytes, IntoBytes, KnownLayout, Immutable};

#[derive(FromBytes, IntoBytes, KnownLayout, Immutable, Debug)]
#[repr(C)]
struct SensorReading {
    sensor_id: u16,
    flags: u8,
    _reserved: u8,
    value: u32,     // Фиксированная точка: реальное значение = value / 1000.0
}

fn parse_sensor(raw: &[u8]) -> Option<&SensorReading> {
    // Безопасное zero-copy: раскладка типа проверяется на этапе компиляции (через derive),
    // а размер и выравнивание конкретного среза проверяются при вызове.
    // При несоответствии возвращается None.
    SensorReading::ref_from_bytes(raw).ok()
    // Возвращает &SensorReading, указывающий ВНУТРЬ raw: без копирования и выделения памяти
}

// --- bytemuck: простой, проверенный временем ---
// Cargo.toml: bytemuck = { version = "1", features = ["derive"] }

use bytemuck::{Pod, Zeroable};

#[derive(Pod, Zeroable, Clone, Copy, Debug)]
#[repr(C)]
struct GpuRegister {
    address: u32,
    value: u32,
}

fn cast_registers(data: &[u8]) -> &[GpuRegister] {
    // Безопасное приведение: Pod гарантирует, что все битовые комбинации допустимы
    bytemuck::cast_slice(data)
}
}

Когда что использовать:

ПодходБезопасностьНакладные расходыКогда использовать
Ручной разбор поле за полем✅ БезопасноКопирование полейНебольшие структуры, сложные раскладки
zerocopy✅ БезопасноБез копированияБольшие буферы, много чтений, проверки на этапе компиляции
bytemuck✅ БезопасноБез копированияПростые типы Pod, приведение срезов
unsafe { transmute() }❌ НебезопасноБез копированияКрайняя мера: избегайте в прикладном коде

bytes::Bytes: буферы с подсчётом ссылок

Крейт bytes (его используют tokio, hyper и tonic) предоставляет байтовые буферы без копирования с подсчётом ссылок. Bytes относится к Vec<u8> так же, как Arc<[u8]> относится к владеющим срезам:

use bytes::{Bytes, BytesMut, Buf, BufMut};

fn main() {
    // --- BytesMut: изменяемый буфер для построения данных ---
    let mut buf = BytesMut::with_capacity(1024);
    buf.put_u8(0x01);                    // Записать байт
    buf.put_u16(0x1234);                 // Записать u16 (big-endian)
    buf.put_slice(b"hello");             // Записать сырые байты
    buf.put(&b"world"[..]);              // Записать из среза

    // Заморозить в неизменяемые Bytes (бесплатно):
    let data: Bytes = buf.freeze();

    // --- Bytes: неизменяемый, с подсчётом ссылок, клонируемый ---
    let data2 = data.clone();            // Дёшево: увеличивает счётчик ссылок, НЕ глубокое копирование
    let slice = data.slice(3..8);        // Подсрез без копирования (разделяет буфер)

    // Чтение из Bytes через трейт Buf:
    let mut reader = &data[..];
    let byte = reader.get_u8();          // 0x01
    let short = reader.get_u16();        // 0x1234

    // Разделение без копирования:
    let mut original = Bytes::from_static(b"HEADER\x00PAYLOAD");
    let header = original.split_to(6);   // header = "HEADER", original = "\x00PAYLOAD"

    println!("заголовок: {:?}", &header[..]);
    println!("полезная нагрузка: {:?}", &original[1..]);
}

bytes против Vec<u8>:

СвойствоVec<u8>Bytes
Стоимость клонированияO(n): глубокое копированиеO(1): увеличение счётчика ссылок
ПодсрезыЗаимствуются с временем жизниВладеющие, с отслеживанием счётчика ссылок
ПотокобезопасностьНе Sync (нужен Arc)Send + Sync встроены
ИзменяемостьПрямой &mutСначала разделить на BytesMut
ЭкосистемаСтандартная библиотекаtokio, hyper, tonic, axum

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

Ключевые выводы: сериализация и бинарные данные

  • Derive-макросы serde покрывают 90% случаев; для остальных используйте атрибуты (rename, skip, default)
  • Десериализация без копирования (&'a str в структурах) исключает выделения памяти для сценариев с интенсивным чтением
  • repr(C) с zerocopy или bytemuck для раскладок регистров оборудования; bytes::Bytes для буферов с подсчётом ссылок

См. также: гл. 10 — Обработка ошибок о совмещении ошибок serde с thiserror. гл. 12 — Unsafe о repr(C) и раскладках данных для FFI.

flowchart LR
    subgraph Input["Вход"]
        JSON["JSON"]
        TOML["TOML"]
        Bin["bincode"]
        MsgP["MessagePack"]
    end

    subgraph serde["Модель данных serde"]
        Ser["Serialize"]
        De["Deserialize"]
    end

    subgraph Output["Выход"]
        Struct["Структура Rust"]
        Enum["Перечисление Rust"]
    end

    JSON --> De
    TOML --> De
    Bin --> De
    MsgP --> De
    De --> Struct
    De --> Enum
    Struct --> Ser
    Enum --> Ser
    Ser --> JSON
    Ser --> Bin

    style JSON fill:#e8f4f8,stroke:#2980b9,color:#000
    style TOML fill:#e8f4f8,stroke:#2980b9,color:#000
    style Bin fill:#e8f4f8,stroke:#2980b9,color:#000
    style MsgP fill:#e8f4f8,stroke:#2980b9,color:#000
    style Ser fill:#fef9e7,stroke:#f1c40f,color:#000
    style De fill:#fef9e7,stroke:#f1c40f,color:#000
    style Struct fill:#d4efdf,stroke:#27ae60,color:#000
    style Enum fill:#d4efdf,stroke:#27ae60,color:#000

Упражнение: собственная десериализация serde ★★★ (~45 минут)

Спроектируйте обёртку HumanDuration, которая десериализуется из читаемых человеком строк вроде "30s", "5m", "2h" с помощью собственного десериализатора serde. Она также должна сериализоваться обратно в тот же формат.

🔑 Решение
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;

#[derive(Debug, Clone, PartialEq)]
struct HumanDuration(std::time::Duration);

impl HumanDuration {
    fn from_str(s: &str) -> Result<Self, String> {
        let s = s.trim();
        if s.is_empty() { return Err("пустая строка длительности".into()); }

        let (num_str, suffix) = s.split_at(
            s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len())
        );
        let value: u64 = num_str.parse()
            .map_err(|_| format!("некорректное число: {num_str}"))?;

        let duration = match suffix {
            "s" | "sec"  => std::time::Duration::from_secs(value),
            "m" | "min"  => std::time::Duration::from_secs(value * 60),
            "h" | "hr"   => std::time::Duration::from_secs(value * 3600),
            "ms"         => std::time::Duration::from_millis(value),
            other        => return Err(format!("неизвестный суффикс: {other}")),
        };
        Ok(HumanDuration(duration))
    }
}

impl fmt::Display for HumanDuration {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let secs = self.0.as_secs();
        if secs == 0 {
            write!(f, "{}ms", self.0.as_millis())
        } else if secs % 3600 == 0 {
            write!(f, "{}h", secs / 3600)
        } else if secs % 60 == 0 {
            write!(f, "{}m", secs / 60)
        } else {
            write!(f, "{}s", secs)
        }
    }
}

impl Serialize for HumanDuration {
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        serializer.serialize_str(&self.to_string())
    }
}

impl<'de> Deserialize<'de> for HumanDuration {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        let s = String::deserialize(deserializer)?;
        HumanDuration::from_str(&s).map_err(serde::de::Error::custom)
    }
}

#[derive(Debug, Deserialize, Serialize)]
struct Config {
    timeout: HumanDuration,
    retry_interval: HumanDuration,
}

fn main() {
    let json = r#"{ "timeout": "30s", "retry_interval": "5m" }"#;
    let config: Config = serde_json::from_str(json).unwrap();

    assert_eq!(config.timeout.0, std::time::Duration::from_secs(30));
    assert_eq!(config.retry_interval.0, std::time::Duration::from_secs(300));

    let serialized = serde_json::to_string(&config).unwrap();
    assert!(serialized.contains("30s"));
    println!("Конфигурация: {serialized}");
}

12. Unsafe Rust: контролируемая опасность 🔴

Что вы узнаете:

  • Пять сверхспособностей unsafe и когда нужна каждая из них
  • Как писать корректные абстракции: безопасный API поверх небезопасного кода
  • Паттерны FFI для вызова C из Rust (и наоборот)
  • Распространённые ловушки UB и паттерны арена- и slab-аллокаторов

Пять сверхспособностей unsafe

unsafe открывает пять операций, которые компилятор не может проверить:

#![allow(unused)]
fn main() {
// SAFETY: каждая операция объяснена ниже, по месту.
unsafe {
    // 1. Разыменование сырого указателя
    let ptr: *const i32 = &42;
    let value = *ptr; // Может быть висячим или нулевым указателем

    // 2. Вызов небезопасной функции
    let layout = std::alloc::Layout::new::<u64>();
    let mem = std::alloc::alloc(layout);

    // 3. Доступ к изменяемой статической переменной
    static mut COUNTER: u32 = 0;
    COUNTER += 1; // Гонка данных, если к ней обращаются несколько потоков

    // 4. Реализация небезопасного трейта
    // unsafe impl Send for MyType {}

    // 5. Доступ к полям union
    // union IntOrFloat { i: i32, f: f32 }
    // let u = IntOrFloat { i: 42 };
    // let f = u.f; // Переинтерпретация битов: результат может быть мусором
}
}

Ключевой принцип: unsafe не отключает проверку заимствований и систему типов. Он открывает только эти пять конкретных возможностей. Все остальные правила Rust по-прежнему действуют.

Написание корректных абстракций

Назначение unsafe в том, чтобы построить безопасные абстракции вокруг небезопасных операций:

#![allow(unused)]
fn main() {
/// Буфер фиксированной ёмкости, размещённый на стеке.
/// Все публичные методы безопасны: unsafe инкапсулирован.
pub struct StackBuf<T, const N: usize> {
    data: [std::mem::MaybeUninit<T>; N],
    len: usize,
}

impl<T, const N: usize> StackBuf<T, N> {
    pub fn new() -> Self {
        StackBuf {
            // Каждый элемент — отдельный MaybeUninit: unsafe не нужен.
            // Блоки `const { ... }` (Rust 1.79+) позволяют повторить
            // константное выражение, не реализующее Copy, N раз.
            data: [const { std::mem::MaybeUninit::uninit() }; N],
            len: 0,
        }
    }

    pub fn push(&mut self, value: T) -> Result<(), T> {
        if self.len >= N {
            return Err(value); // Буфер заполнен: возвращаем значение вызывающей стороне
        }
        // SAFETY: len < N, поэтому data[len] находится в пределах.
        // Записываем корректный T в ячейку MaybeUninit.
        self.data[self.len] = std::mem::MaybeUninit::new(value);
        self.len += 1;
        Ok(())
    }

    pub fn get(&self, index: usize) -> Option<&T> {
        if index < self.len {
            // SAFETY: index < len, и data[0..len] все инициализированы.
            Some(unsafe { self.data[index].assume_init_ref() })
        } else {
            None
        }
    }
}

impl<T, const N: usize> Drop for StackBuf<T, N> {
    fn drop(&mut self) {
        // SAFETY: data[0..len] инициализированы: уничтожаем их правильно.
        for i in 0..self.len {
            unsafe { self.data[i].assume_init_drop(); }
        }
    }
}
}

Три правила корректного unsafe-кода:

  1. Документируйте инварианты: каждый комментарий // SAFETY: объясняет, почему операция допустима
  2. Инкапсулируйте: unsafe находится внутри безопасного API, и пользователи не могут вызвать UB
  3. Минимизируйте: unsafe-блок должен быть как можно меньше

Паттерны FFI: вызов C из Rust

#![allow(unused)]
fn main() {
// Объявляем сигнатуру функции C:
extern "C" {
    fn strlen(s: *const std::ffi::c_char) -> usize;
    fn printf(format: *const std::ffi::c_char, ...) -> std::ffi::c_int;
}

// Безопасная обёртка:
fn safe_strlen(s: &str) -> usize {
    let c_string = std::ffi::CString::new(s).expect("строка содержит нулевой байт");
    // SAFETY: c_string — корректная строка с нулевым завершителем, живёт на время вызова.
    unsafe { strlen(c_string.as_ptr()) }
}

// Вызов Rust из C (экспорт функции):
#[no_mangle]
pub extern "C" fn rust_add(a: i32, b: i32) -> i32 {
    a + b
}
}

Распространённые типы FFI:

RustCПримечания
i32 / u32int32_t / uint32_tФиксированная ширина, безопасно
*const T / *mut Tconst T* / T*Сырые указатели
std::ffi::CStrconst char* (заимствованная)С нулевым завершителем, заимствованная
std::ffi::CStringchar* (владеющая)С нулевым завершителем, владеющая
std::ffi::c_voidvoidНепрозрачная цель указателя
Option<fn(...)>Указатель на функцию, который может быть NULLNone = NULL

Распространённые ловушки UB

ЛовушкаПримерПочему это UB
Разыменование null*std::ptr::null::<i32>()Разыменование null всегда UB
Висячий указательРазыменование после drop()Память может быть переиспользована
Гонка данныхДва потока пишут в static mutНесинхронизированная параллельная запись
Неверный assume_initMaybeUninit::<String>::uninit().assume_init()Чтение неинициализированной памяти. Примечание: [const { MaybeUninit::uninit() }; N] (Rust 1.79+) это безопасный способ создать массив MaybeUninit: не нужны ни unsafe, ни assume_init (см. StackBuf::new() выше).
Нарушение псевдонимовСоздание двух &mut на одни и те же данныеНарушает модель псевдонимов Rust
Недопустимое значение перечисленияstd::mem::transmute::<u8, bool>(2)bool может быть только 0 или 1

Когда использовать unsafe в продакшене:

  • Границы FFI (вызов кода на C/C++)
  • Критичные к производительности внутренние циклы (избегаем проверок границ)
  • Создание примитивов (Vec, HashMap: они используют unsafe внутри)
  • Никогда не используйте в прикладной логике, если можно обойтись

Пользовательские аллокаторы: паттерны арен и slab

В C для особых паттернов выделения памяти пишут собственные замены malloc(): арена-аллокаторы, которые освобождают всё разом, slab-аллокаторы для объектов фиксированного размера или пул-аллокаторы для систем с высокой пропускной способностью. Rust даёт ту же мощь через трейт GlobalAlloc и крейты-аллокаторы, плюс дополнительное преимущество: арены с ограничением по времени жизни предотвращают use-after-free на этапе компиляции.

Арена-аллокаторы: массовое выделение, массовое освобождение

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

#![allow(unused)]
fn main() {
use bumpalo::Bump;

fn process_sensor_frame(raw_data: &[u8]) {
    // Создаём арену для выделений этого кадра
    let arena = Bump::new();

    // Выделяем объекты в арене: ~2 нс каждое (просто сдвиг указателя)
    let header = arena.alloc(parse_header(raw_data));
    let readings: &mut [f32] = arena.alloc_slice_fill_default(header.sensor_count);

    for (i, chunk) in raw_data[header.payload_offset..].chunks(4).enumerate() {
        if i < readings.len() {
            readings[i] = f32::from_le_bytes(chunk.try_into().unwrap());
        }
    }

    // Используем readings...
    let avg = readings.iter().sum::<f32>() / readings.len() as f32;
    println!("Средняя по кадру: {avg:.2}");

    // Арена уничтожается здесь: ВСЕ выделения освобождаются разом за O(1)
    // Никаких деструкторов для каждого объекта, никакой фрагментации
}
fn parse_header(_: &[u8]) -> Header { Header { sensor_count: 4, payload_offset: 8 } }
struct Header { sensor_count: usize, payload_offset: usize }
}

Арена против стандартного аллокатора:

АспектVec::new() / Box::new()Арена Bump
Скорость выделения~25 нс (malloc)~2 нс (сдвиг указателя)
Скорость освобожденияДеструктор каждого объектаМассовое освобождение за O(1)
ФрагментацияЕсть (долгоживущие процессы)Нет внутри арены
Безопасность времён жизниКуча: освобождается при DropСсылка на арену: проверяется на этапе компиляции
СценарийУниверсальное назначениеОбработка запросов, кадров, пакетов

typed-arena: арена с контролем типов

Когда все объекты арены одного типа, typed-arena предоставляет более простой API, который возвращает ссылки с временем жизни арены:

#![allow(unused)]
fn main() {
use typed_arena::Arena;

struct AstNode<'a> {
    value: i32,
    children: Vec<&'a AstNode<'a>>,
}

fn build_tree() {
    let arena: Arena<AstNode<'_>> = Arena::new();

    // Выделяем узлы: возвращается &AstNode, привязанный ко времени жизни арены
    let root = arena.alloc(AstNode { value: 1, children: vec![] });
    let left = arena.alloc(AstNode { value: 2, children: vec![] });
    let right = arena.alloc(AstNode { value: 3, children: vec![] });

    // Строим дерево: все ссылки действительны, пока жива `arena`
    // (для по-настоящему изменяемых деревьев нужна внутренняя изменяемость)

    println!("Корень: {}, левый: {}, правый: {}", root.value, left.value, right.value);

    // `arena` уничтожается здесь: все узлы освобождаются разом
}
}

Slab-аллокаторы: пулы объектов фиксированного размера

Slab-аллокатор заранее выделяет пул слотов фиксированного размера. Объекты выделяются и возвращаются по отдельности, но все слоты одного размера. Это устраняет фрагментацию и обеспечивает выделение и освобождение за O(1):

#![allow(unused)]
fn main() {
use slab::Slab;

struct Connection {
    id: u64,
    buffer: [u8; 1024],
    active: bool,
}

fn connection_pool_example() {
    // Заранее выделяем slab для соединений
    let mut connections: Slab<Connection> = Slab::with_capacity(256);

    // insert возвращает ключ (индекс usize): O(1)
    let key1 = connections.insert(Connection {
        id: 1001,
        buffer: [0; 1024],
        active: true,
    });

    let key2 = connections.insert(Connection {
        id: 1002,
        buffer: [0; 1024],
        active: true,
    });

    // Доступ по ключу: O(1)
    if let Some(conn) = connections.get_mut(key1) {
        conn.buffer[0..5].copy_from_slice(b"hello");
    }

    // remove возвращает значение: O(1), слот переиспользуется при следующей вставке
    let removed = connections.remove(key2);
    assert_eq!(removed.id, 1002);

    // Следующая вставка переиспользует освобождённый слот: без фрагментации
    let key3 = connections.insert(Connection {
        id: 1003,
        buffer: [0; 1024],
        active: true,
    });
    assert_eq!(key3, key2); // Тот же слот переиспользован!
}
}

Минимальная арена (для no_std)

Для bare-metal окружений, где нельзя подключить bumpalo, вот минимальная арена на unsafe:

#![allow(unused)]
#![cfg_attr(not(test), no_std)]

fn main() {
use core::alloc::Layout;
use core::cell::{Cell, UnsafeCell};

/// Простой bump-аллокатор на основе массива байтов фиксированного размера.
/// Не потокобезопасен: используйте отдельную арену на ядро или защищайте её блокировкой
/// для многопоточного кода.
///
/// **Важно**: как и `bumpalo`, эта арена НЕ вызывает деструкторы для выделенных
/// объектов при уничтожении арены. Типы с реализацией `Drop` будут утекать
/// свои ресурсы (дескрипторы файлов, сокеты и т. п.). Выделяйте только типы
/// без существенной реализации `Drop` или уничтожайте их вручную до арены.
pub struct FixedArena<const N: usize> {
    // UnsafeCell обязателен здесь: мы изменяем `buf` через `&self`.
    // Без UnsafeCell приведение &self.buf к *mut u8 было бы UB
    // (нарушает модель псевдонимов Rust: разделяемая ссылка подразумевает неизменяемость).
    buf: UnsafeCell<[u8; N]>,
    offset: Cell<usize>, // Внутренняя изменяемость для выделения через &self
}

impl<const N: usize> FixedArena<N> {
    pub const fn new() -> Self {
        FixedArena {
            buf: UnsafeCell::new([0; N]),
            offset: Cell::new(0),
        }
    }

    /// Выделяет `T` в арене. Возвращает `None`, если места нет.
    pub fn alloc<T>(&self, value: T) -> Option<&mut T> {
        let layout = Layout::new::<T>();
        let current = self.offset.get();

        // Выравнивание вверх
        let aligned = (current + layout.align() - 1) & !(layout.align() - 1);
        let new_offset = aligned + layout.size();

        if new_offset > N {
            return None; // Арена заполнена
        }

        self.offset.set(new_offset);

        // SAFETY:
        // - `aligned` находится в пределах `buf` (проверено выше)
        // - Выравнивание корректно (по требованию T)
        // - Без псевдонимов: каждое выделение возвращает уникальную непересекающуюся область
        // - UnsafeCell даёт право изменять через &self
        // - Арена переживёт возвращённую ссылку (это должен обеспечить вызывающий код)
        let ptr = unsafe {
            let base = (self.buf.get() as *mut u8).add(aligned);
            let typed = base as *mut T;
            typed.write(value);
            &mut *typed
        };

        Some(ptr)
    }

    /// Сбрасывает арену: делает недействительными все предыдущие выделения.
    ///
    /// # Safety
    /// Вызывающий код должен гарантировать, что ссылок на данные арены не осталось.
    pub unsafe fn reset(&self) {
        self.offset.set(0);
    }

    pub fn used(&self) -> usize {
        self.offset.get()
    }

    pub fn remaining(&self) -> usize {
        N - self.offset.get()
    }
}
}

Выбор стратегии аллокатора

Примечание: диаграмма ниже использует синтаксис Mermaid. Она отображается на GitHub и в инструментах с поддержкой Mermaid (mdBook с плагином mermaid, VS Code с расширением Mermaid). В простых просмотрщиках Markdown вы увидите исходный код.

graph TD
    A["Какой у вас паттерн выделения памяти?"] --> B{Все одного типа?}
    A --> I{"Окружение?"}
    B -->|Да| C{Нужно освобождать по отдельности?}
    B -->|Нет| D{Нужно освобождать по отдельности?}
    C -->|Да| E["<b>Slab</b><br/>крейт slab<br/>Выделение и освобождение за O(1)<br/>Доступ по индексу"]
    C -->|Нет| F["<b>typed-arena</b><br/>Массовое выделение и освобождение<br/>Ссылки с ограниченным временем жизни"]
    D -->|Да| G["<b>Стандартный аллокатор</b><br/>Box, Vec и др.<br/>Универсальный malloc"]
    D -->|Нет| H["<b>Bump-арена</b><br/>крейт bumpalo<br/>~2 нс на выделение, массовое освобождение за O(1)"]

    I -->|no_std| J["FixedArena (своя)<br/>или embedded-alloc"]
    I -->|std| K["bumpalo / typed-arena / slab"]

    style E fill:#91e5a3,color:#000
    style F fill:#91e5a3,color:#000
    style G fill:#89CFF0,color:#000
    style H fill:#91e5a3,color:#000
    style J fill:#ffa07a,color:#000
    style K fill:#91e5a3,color:#000
Паттерн CАналог в RustКлючевое преимущество
Пул поверх malloc()Реализация #[global_allocator]Типобезопасно, удобно для отладки
obstack (GNU)bumpalo::BumpОграничено временем жизни, без use-after-free
Slab ядра (kmem_cache)slab::Slab<T>Типобезопасно, доступ по индексу
Временный буфер на стекеFixedArena<N> (выше)Без кучи, создаётся в const-контексте
alloca()[T; N] или SmallVecРазмер известен на этапе компиляции, без UB

Перекрёстная ссылка: настройка аллокатора для bare-metal (#[global_allocator] с embedded-alloc) описана в книге Rust Training for C Programmers, глава 15.1 «Global Allocator Setup», где рассмотрена начальная загрузка, специфичная для встраиваемых систем.

Ключевые выводы: unsafe Rust

  • Документируйте инварианты (комментарии SAFETY:), инкапсулируйте их за безопасными API и минимизируйте область unsafe
  • [const { MaybeUninit::uninit() }; N] (Rust 1.79+) заменяет устаревший антипаттерн с assume_init
  • FFI требует extern "C", #[repr(C)] и аккуратной обработки null и времён жизни
  • Арена- и slab-аллокаторы обменивают универсальность на скорость выделения

См. также: гл. 4 — PhantomData о взаимодействии вариантности и проверки drop с unsafe-кодом. гл. 9 — Умные указатели о Pin и самоссылающихся типах.


Упражнение: безопасная обёртка над unsafe ★★★ (~45 минут)

Напишите FixedVec<T, const N: usize>: вектор фиксированной ёмкости, размещённый на стеке. Требования:

  • push(&mut self, value: T) -> Result<(), T> возвращает Err(value), когда вектор заполнен
  • pop(&mut self) -> Option<T> возвращает и удаляет последний элемент
  • as_slice(&self) -> &[T] заимствует инициализированные элементы
  • Все публичные методы должны быть безопасными; весь unsafe инкапсулирован с комментариями SAFETY:
  • Drop должен очищать инициализированные элементы
🔑 Решение
use std::mem::MaybeUninit;

pub struct FixedVec<T, const N: usize> {
    data: [MaybeUninit<T>; N],
    len: usize,
}

impl<T, const N: usize> FixedVec<T, N> {
    pub fn new() -> Self {
        FixedVec {
            data: [const { MaybeUninit::uninit() }; N],
            len: 0,
        }
    }

    pub fn push(&mut self, value: T) -> Result<(), T> {
        if self.len >= N { return Err(value); }
        // SAFETY: len < N, поэтому data[len] находится в пределах.
        self.data[self.len] = MaybeUninit::new(value);
        self.len += 1;
        Ok(())
    }

    pub fn pop(&mut self) -> Option<T> {
        if self.len == 0 { return None; }
        self.len -= 1;
        // SAFETY: data[len] был инициализирован (len был > 0 до уменьшения).
        Some(unsafe { self.data[self.len].assume_init_read() })
    }

    pub fn as_slice(&self) -> &[T] {
        // SAFETY: data[0..len] инициализированы, и MaybeUninit<T>
        // имеет ту же раскладку, что и T.
        unsafe { std::slice::from_raw_parts(self.data.as_ptr() as *const T, self.len) }
    }

    pub fn len(&self) -> usize { self.len }
    pub fn is_empty(&self) -> bool { self.len == 0 }
}

impl<T, const N: usize> Drop for FixedVec<T, N> {
    fn drop(&mut self) {
        // SAFETY: data[0..len] инициализированы: уничтожаем каждый.
        for i in 0..self.len {
            unsafe { self.data[i].assume_init_drop(); }
        }
    }
}

fn main() {
    let mut v = FixedVec::<String, 4>::new();
    v.push("hello".into()).unwrap();
    v.push("world".into()).unwrap();
    assert_eq!(v.as_slice(), &["hello", "world"]);
    assert_eq!(v.pop(), Some("world".into()));
    assert_eq!(v.len(), 1);
}

13. Макросы: код, который пишет код 🟡

Что вы узнаете:

  • Декларативные макросы (macro_rules!) с сопоставлением с образцом и повторениями
  • Когда макросы подходят лучше обобщений и трейтов
  • Процедурные макросы: derive-, атрибутные и функциональные
  • Написание собственного derive-макроса с syn и quote

Декларативные макросы (macro_rules!)

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

#![allow(unused)]
fn main() {
// Простой макрос, который создаёт HashMap
macro_rules! hashmap {
    // Сопоставление: пары «ключ => значение», разделённые запятыми
    ( $( $key:expr => $value:expr ),* $(,)? ) => {
        {
            let mut map = std::collections::HashMap::new();
            $( map.insert($key, $value); )*
            map
        }
    };
}

let scores = hashmap! {
    "Alice" => 95,
    "Bob" => 87,
    "Carol" => 92,
};
// Раскрывается в:
// let mut map = HashMap::new();
// map.insert("Alice", 95);
// map.insert("Bob", 87);
// map.insert("Carol", 92);
// map
}

Типы фрагментов в макросах:

ФрагментЧто сопоставляетПример
$x:exprЛюбое выражение42, a + b, foo()
$x:tyТипi32, Vec<String>
$x:identИдентификаторmy_var, Config
$x:patОбразецSome(x), _
$x:stmtИнструкциюlet x = 5;
$x:ttОдно дерево токеновЧто угодно (самый гибкий)
$x:literalЛитерал42, "hello", true

Повторения: $( ... ),* означает «ноль или больше, разделённых запятыми»

#![allow(unused)]
fn main() {
// Автоматически генерируем тестовые функции
macro_rules! test_cases {
    ( $( $name:ident: $input:expr => $expected:expr ),* $(,)? ) => {
        $(
            #[test]
            fn $name() {
                assert_eq!(process($input), $expected);
            }
        )*
    };
}

test_cases! {
    test_empty: "" => "",
    test_hello: "hello" => "HELLO",
    test_trim: "  spaces  " => "SPACES",
}
// Генерирует три отдельные функции #[test]
}

Когда (не) стоит использовать макросы

Используйте макросы, когда:

  • Нужно убрать шаблонный код, с которым не справляются трейты и обобщения (вариативные аргументы, генерация тестов без повторов, DRY)
  • Создаёте предметно-ориентированный язык (DSL), например html!, sql!, vec!
  • Нужна условная генерация кода (cfg!, compile_error!)

Не используйте макросы, когда:

  • Подойдёт функция или обобщение (макросы труднее отлаживать, и автодополнение в них не помогает)
  • Внутри макроса нужна проверка типов (макросы работают с токенами, а не с типами)
  • Шаблон используется один-два раза (абстракция не стоит того)
#![allow(unused)]
fn main() {
// ❌ Лишний макрос: функция справляется
macro_rules! double {
    ($x:expr) => { $x * 2 };
}

// ✅ Просто используйте функцию:
fn double(x: i32) -> i32 { x * 2 }

// ✅ Хорошее применение макроса: вариативные аргументы, не может быть функцией:
macro_rules! println {
    ($($arg:tt)*) => { /* строка формата + аргументы */ };
}
}

Обзор процедурных макросов

Процедурные макросы это функции на Rust, которые преобразуют потоки токенов. Они требуют отдельного крейта с proc-macro = true:

#![allow(unused)]
fn main() {
// Три вида процедурных макросов:

// 1. Derive-макросы — #[derive(MyTrait)]
// Генерируют реализации трейтов по определениям структур
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Config {
    name: String,
    port: u16,
}

// 2. Атрибутные макросы — #[my_attribute]
// Преобразуют помеченный элемент
#[route(GET, "/api/users")]
async fn list_users() -> Json<Vec<User>> { /* ... */ }

// 3. Функциональные макросы — my_macro!(...)
// Пользовательский синтаксис
let query = sql!(SELECT * FROM users WHERE id = ?);
}

Derive-макросы на практике

Это самый распространённый вид процедурных макросов. Вот как концептуально работает #[derive(Debug)]:

#![allow(unused)]
fn main() {
// Вход (ваша структура):
#[derive(Debug)]
struct Point {
    x: f64,
    y: f64,
}

// Derive-макрос генерирует:
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-макросы:

DeriveКрейтЧто генерирует
DebugstdРеализацию fmt::Debug (отладочный вывод)
Clone, CopystdДублирование значений
PartialEq, EqstdСравнение на равенство
HashstdХеширование для ключей HashMap
Serialize, DeserializeserdeКодирование в JSON, YAML и др.
Errorthiserrorstd::error::Error и Display
ParserclapРазбор аргументов командной строки
Builderderive_builderПаттерн builder

Практический совет: используйте derive-макросы без колебаний: они убирают подверженный ошибкам шаблонный код. Написание собственных процедурных макросов относится к продвинутым темам, поэтому сначала используйте готовые (serde, thiserror, clap).

Гигиена макросов и $crate

Гигиена означает, что идентификаторы, созданные внутри макроса, не конфликтуют с идентификаторами в области видимости вызывающего кода. macro_rules! в Rust частично гигиеничен:

macro_rules! make_var {
    () => {
        let x = 42; // Этот 'x' находится в области видимости МАКРОСА
    };
}

fn main() {
    let x = 10;
    make_var!();   // Создаёт другой 'x' (гигиенично)
    println!("{x}"); // Выведет 10, а не 42: x из макроса не просачивается наружу
}

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

#![allow(unused)]
fn main() {
// В крейте my_diagnostics:

pub fn log_result(msg: &str) {
    println!("[diag] {msg}");
}

#[macro_export]
macro_rules! diag_log {
    ($($arg:tt)*) => {
        // ✅ $crate всегда ссылается на my_diagnostics, даже если пользователь
        // переименовал крейт в своём Cargo.toml
        $crate::log_result(&format!($($arg)*))
    };
}

// ❌ Без $crate:
// my_diagnostics::log_result(...)  ← сломается, если пользователь напишет:
//   [dependencies]
//   diag = { package = "my_diagnostics", version = "1" }
}

Правило: всегда используйте $crate:: в макросах с #[macro_export]. Никогда не используйте имя своего крейта напрямую.

Рекурсивные макросы и tt munching

Рекурсивные макросы обрабатывают вход по одному токену за раз. Этот приём называется tt munching (token-tree munching, «поедание» деревьев токенов):

// Считаем, сколько выражений передано в макрос
macro_rules! count {
    // Базовый случай: токенов не осталось
    () => { 0usize };
    // Рекурсивный случай: забираем одно выражение и считаем остальные
    ($head:expr $(, $tail:expr)* $(,)?) => {
        1usize + count!($($tail),*)
    };
}

fn main() {
    let n = count!("a", "b", "c", "d");
    assert_eq!(n, 4);

    // Работает и на этапе компиляции:
    const N: usize = count!(1, 2, 3);
    assert_eq!(N, 3);
}
#![allow(unused)]
fn main() {
// Строим гетерогенный кортеж из списка выражений:
macro_rules! tuple_from {
    // Базовый случай: один элемент
    ($single:expr $(,)?) => { ($single,) };
    // Рекурсивный случай: первый элемент и остальные
    ($head:expr, $($tail:expr),+ $(,)?) => {
        ($head, tuple_from!($($tail),+))
    };
}

let t = tuple_from!(1, "hello", 3.14, true);
// Раскрывается в: (1, ("hello", (3.14, (true,))))
}

Тонкости фрагментов:

ФрагментПодвох
$x:exprЖадный разбор: 1 + 2 это ОДНО выражение, а не три токена
$x:tyЖадный разбор: Vec<String> это один тип; за ним нельзя ставить + или <
$x:ttСопоставляет ровно ОДНО дерево токенов: самый гибкий, но меньше всего проверяется
$x:identТолько простые идентификаторы, а не пути вроде std::io
$x:patВ Rust 2021 сопоставляет шаблоны A | B; для одиночных образцов используйте $x:pat_param

Когда использовать tt: когда нужно передать токены другому макросу, не ограничивая их парсером. Шаблон $($args:tt)* означает «принять всё» (его используют println!, format!, vec!).

Пишем derive-макрос с syn и quote

Derive-макросы живут в отдельном крейте (proc-macro = true) и преобразуют поток токенов с помощью syn (разбор Rust) и quote (генерация Rust):

# my_derive/Cargo.toml
[lib]
proc-macro = true

[dependencies]
syn = { version = "2", features = ["full"] }
quote = "1"
proc-macro2 = "1"
#![allow(unused)]
fn main() {
// my_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

/// Derive-макрос, который генерирует метод `describe()`,
/// возвращающий имя структуры и имена её полей.
#[proc_macro_derive(Describe)]
pub fn derive_describe(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let name = &input.ident;
    let name_str = name.to_string();

    // Извлекаем имена полей (только для структур с именованными полями)
    let fields = match &input.data {
        syn::Data::Struct(data) => {
            data.fields.iter()
                .filter_map(|f| f.ident.as_ref())
                .map(|id| id.to_string())
                .collect::<Vec<_>>()
        }
        _ => vec![],
    };

    let field_list = fields.join(", ");

    let expanded = quote! {
        impl #name {
            pub fn describe() -> String {
                format!("{} {{ {} }}", #name_str, #field_list)
            }
        }
    };

    TokenStream::from(expanded)
}
}
// В приложении:
use my_derive::Describe;

#[derive(Describe)]
struct SensorReading {
    sensor_id: u16,
    value: f64,
    timestamp: u64,
}

fn main() {
    println!("{}", SensorReading::describe());
    // "SensorReading { sensor_id, value, timestamp }"
}

Рабочий процесс: TokenStream (сырые токены) → syn::parse (AST) → анализ и преобразование → quote! (генерация токенов) → TokenStream (обратно компилятору).

КрейтРольКлючевые типы
proc-macroИнтерфейс с компиляторомTokenStream
synРазбор исходного кода Rust в ASTDeriveInput, ItemFn, Type
quoteГенерация токенов Rust из шаблоновquote!{}, интерполяция #variable
proc-macro2Мост между syn/quote и proc-macroTokenStream, Span

Практический совет: прежде чем писать собственный макрос, изучите исходники простого derive-макроса, например thiserror или derive_more. Команда cargo expand (через cargo-expand) показывает, во что раскрывается любой макрос: это бесценно для отладки.

Ключевые выводы: макросы

  • macro_rules! для простой генерации кода; процедурные макросы (syn и quote) для сложных derive-макросов
  • По возможности предпочитайте обобщения и трейты макросам: макросы труднее отлаживать и поддерживать
  • $crate обеспечивает гигиену; tt munching даёт рекурсивное сопоставление с образцом

См. также: гл. 2 — Трейты о том, когда трейты и обобщения лучше макросов. гл. 14 — Тестирование о тестировании кода, сгенерированного макросами.

flowchart LR
    A["Исходный код"] --> B["macro_rules!<br>сопоставление с образцом"]
    A --> C["#[derive(MyMacro)]<br>процедурный макрос"]

    B --> D["Раскрытие токенов"]
    C --> E["syn: разбор AST"]
    E --> F["Преобразование"]
    F --> G["quote!: генерация токенов"]
    G --> D

    D --> H["Скомпилированный код"]

    style A fill:#e8f4f8,stroke:#2980b9,color:#000
    style B fill:#d4efdf,stroke:#27ae60,color:#000
    style C fill:#fdebd0,stroke:#e67e22,color:#000
    style D fill:#fef9e7,stroke:#f1c40f,color:#000
    style E fill:#fdebd0,stroke:#e67e22,color:#000
    style F fill:#fdebd0,stroke:#e67e22,color:#000
    style G fill:#fdebd0,stroke:#e67e22,color:#000
    style H fill:#d4efdf,stroke:#27ae60,color:#000

Упражнение: декларативный макрос map! ★ (~15 минут)

Напишите макрос map!, который создаёт HashMap из пар ключ-значение:

let m = map! {
    "host" => "localhost",
    "port" => "8080",
};
assert_eq!(m.get("host"), Some(&"localhost"));

Требования: поддержите завершающую запятую и пустой вызов map!{}.

🔑 Решение
macro_rules! map {
    () => { std::collections::HashMap::new() };
    ( $( $key:expr => $val:expr ),+ $(,)? ) => {{
        let mut m = std::collections::HashMap::new();
        $( m.insert($key, $val); )+
        m
    }};
}

fn main() {
    let config = map! {
        "host" => "localhost",
        "port" => "8080",
        "timeout" => "30",
    };
    assert_eq!(config.len(), 3);
    assert_eq!(config["host"], "localhost");

    let empty: std::collections::HashMap<String, String> = map!();
    assert!(empty.is_empty());

    let scores = map! { 1 => 100, 2 => 200 };
    assert_eq!(scores[&1], 100);
}

14. Паттерны тестирования и бенчмаркинга 🟢

Что вы узнаете:

  • Три уровня тестов в Rust: модульные, интеграционные и doc-тесты
  • Тестирование свойств (property-based testing) с proptest для поиска граничных случаев
  • Бенчмаркинг с criterion для надёжного измерения производительности
  • Стратегии моков без громоздких фреймворков

Модульные тесты, интеграционные тесты, doc-тесты

В Rust есть три уровня тестирования, встроенные в язык:

#![allow(unused)]
fn main() {
// --- Модульные тесты: в том же файле, что и код ---
pub fn factorial(n: u64) -> u64 {
    (1..=n).product()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_factorial_zero() {
        // (1..=0).product() возвращает 1: единица умножения для пустого диапазона
        assert_eq!(factorial(0), 1);
    }

    #[test]
    fn test_factorial_five() {
        assert_eq!(factorial(5), 120);
    }

    #[test]
    #[cfg(debug_assertions)] // Проверки переполнения включены только в отладочном режиме
    #[should_panic(expected = "overflow")]
    fn test_factorial_overflow() {
        // ⚠️ Этот тест проходит только в отладочном режиме (проверки переполнения включены).
        // В релизном режиме (`cargo test --release`) арифметика u64 молча переполняется,
        // и паники не происходит. Для безопасности в релизе используйте `checked_mul`
        // или настройку профиля `overflow-checks = true`.
        factorial(100); // Должна паниковать при переполнении
    }

    #[test]
    fn test_with_result() -> Result<(), Box<dyn std::error::Error>> {
        // Тесты могут возвращать Result: внутри работает `?`!
        let value: u64 = "42".parse()?;
        assert_eq!(value, 42);
        Ok(())
    }
}
}
#![allow(unused)]
fn main() {
// --- Интеграционные тесты: в директории tests/ ---
// tests/integration_test.rs
// Эти тесты проверяют ТОЛЬКО публичный API вашего крейта

use my_crate::factorial;

#[test]
fn test_factorial_from_outside() {
    assert_eq!(factorial(10), 3_628_800);
}
}
#![allow(unused)]
fn main() {
// --- Doc-тесты: в комментариях к документации ---
/// Вычисляет факториал `n`.
///
/// # Примеры
///
/// ```
/// use my_crate::factorial;
/// assert_eq!(factorial(5), 120);
/// ```
///
/// # Паника
///
/// Паникует, если результат переполняет `u64`.
///
/// ```should_panic
/// my_crate::factorial(100);
/// ```
pub fn factorial(n: u64) -> u64 {
    (1..=n).product()
}
// Doc-тесты компилируются и запускаются через `cargo test`: они не дают примерам устареть.
}

Фикстуры и подготовка тестов

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    // Общая подготовка: вынесем в вспомогательную функцию
    fn setup_database() -> TestDb {
        let db = TestDb::new_in_memory();
        db.run_migrations();
        db.seed_test_data();
        db
    }

    #[test]
    fn test_user_creation() {
        let db = setup_database();
        let user = db.create_user("Alice", "alice@test.com").unwrap();
        assert_eq!(user.name, "Alice");
    }

    #[test]
    fn test_user_deletion() {
        let db = setup_database();
        db.create_user("Bob", "bob@test.com").unwrap();
        assert!(db.delete_user("Bob").is_ok());
        assert!(db.get_user("Bob").is_none());
    }

    // Очистка через Drop (RAII):
    struct TempDir {
        path: std::path::PathBuf,
    }

    impl TempDir {
        fn new() -> Self {
            // Cargo.toml: rand = "0.8"
            let path = std::env::temp_dir().join(format!("test_{}", rand::random::<u32>()));
            std::fs::create_dir_all(&path).unwrap();
            TempDir { path }
        }
    }

    impl Drop for TempDir {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.path);
        }
    }

    #[test]
    fn test_file_operations() {
        let dir = TempDir::new(); // Создан
        std::fs::write(dir.path.join("test.txt"), "hello").unwrap();
        assert!(dir.path.join("test.txt").exists());
    } // dir уничтожается здесь → временный каталог удаляется
}
}

Тестирование свойств (proptest)

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

#![allow(unused)]
fn main() {
// Cargo.toml: proptest = "1"
use proptest::prelude::*;

fn reverse(v: &[i32]) -> Vec<i32> {
    v.iter().rev().cloned().collect()
}

proptest! {
    #[test]
    fn test_reverse_twice_is_identity(v in prop::collection::vec(any::<i32>(), 0..100)) {
        // Свойство: двойной разворот возвращает исходный вектор
        assert_eq!(reverse(&reverse(&v)), v);
    }

    #[test]
    fn test_reverse_preserves_length(v in prop::collection::vec(any::<i32>(), 0..100)) {
        // Свойство: разворот сохраняет длину
        assert_eq!(reverse(&v).len(), v.len());
    }

    #[test]
    fn test_sort_is_idempotent(mut v in prop::collection::vec(any::<i32>(), 0..100)) {
        v.sort();
        let sorted_once = v.clone();
        v.sort();
        assert_eq!(v, sorted_once); // Двойная сортировка = однократная сортировка
    }

    #[test]
    fn test_parse_roundtrip(x in any::<f64>().prop_filter("finite", |x| x.is_finite())) {
        // Свойство: форматирование, затем разбор возвращают то же значение
        let s = format!("{x}");
        let parsed: f64 = s.parse().unwrap();
        prop_assert!((x - parsed).abs() < f64::EPSILON);
    }
}
}

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

Бенчмаркинг с criterion

#![allow(unused)]
fn main() {
// Cargo.toml:
// [dev-dependencies]
// criterion = { version = "0.5", features = ["html_reports"] }
//
// [[bench]]
// name = "my_benchmarks"
// harness = false

// benches/my_benchmarks.rs
use criterion::{criterion_group, criterion_main, Criterion, black_box};

fn fibonacci(n: u64) -> u64 {
    match n {
        0 | 1 => n,
        _ => fibonacci(n - 1) + fibonacci(n - 2),
    }
}

fn bench_fibonacci(c: &mut Criterion) {
    c.bench_function("fibonacci 20", |b| {
        b.iter(|| fibonacci(black_box(20)))
    });

    // Сравниваем разные входные данные:
    let mut group = c.benchmark_group("fibonacci_compare");
    for size in [10, 15, 20, 25] {
        group.bench_with_input(
            criterion::BenchmarkId::from_parameter(size),
            &size,
            |b, &size| b.iter(|| fibonacci(black_box(size))),
        );
    }
    group.finish();
}

criterion_group!(benches, bench_fibonacci);
criterion_main!(benches);

// Запуск: cargo bench
// Создаёт HTML-отчёты в target/criterion/
}

Стратегии моков без фреймворков

Система трейтов Rust даёт естественное внедрение зависимостей, и никакой фреймворк для моков не нужен:

#![allow(unused)]
fn main() {
// Описываем поведение через трейт
trait Clock {
    fn now(&self) -> std::time::Instant;
}

trait HttpClient {
    fn get(&self, url: &str) -> Result<String, String>;
}

// Реализации для продакшена
struct RealClock;
impl Clock for RealClock {
    fn now(&self) -> std::time::Instant { std::time::Instant::now() }
}

// Сервис зависит от абстракций
struct CacheService<C: Clock, H: HttpClient> {
    clock: C,
    client: H,
    ttl: std::time::Duration,
}

impl<C: Clock, H: HttpClient> CacheService<C, H> {
    fn fetch(&self, url: &str) -> Result<String, String> {
        // Использует self.clock и self.client: их можно подменить
        self.client.get(url)
    }
}

// Тест с моками-реализациями: никакой фреймворк не нужен!
#[cfg(test)]
mod tests {
    use super::*;

    struct MockClock {
        fixed_time: std::time::Instant,
    }
    impl Clock for MockClock {
        fn now(&self) -> std::time::Instant { self.fixed_time }
    }

    struct MockHttpClient {
        response: String,
    }
    impl HttpClient for MockHttpClient {
        fn get(&self, _url: &str) -> Result<String, String> {
            Ok(self.response.clone())
        }
    }

    #[test]
    fn test_cache_service() {
        let service = CacheService {
            clock: MockClock { fixed_time: std::time::Instant::now() },
            client: MockHttpClient { response: "cached data".into() },
            ttl: std::time::Duration::from_secs(300),
        };

        assert_eq!(service.fetch("http://example.com").unwrap(), "cached data");
    }
}
}

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

Ключевые выводы: тестирование

  • Doc-тесты (///) одновременно служат документацией и регрессионными тестами: их компилируют и запускают
  • proptest генерирует случайные входные данные, чтобы находить граничные случаи, которые вы никогда не написали бы вручную
  • criterion даёт статистически обоснованные бенчмарки с HTML-отчётами
  • Мокайте через обобщения трейтов и тестовые двойники, а не через фреймворки для моков

См. также: гл. 13 — Макросы о тестировании кода, сгенерированного макросами. гл. 15 — Дизайн API о том, как структура модулей влияет на организацию тестов.


Упражнение: тестирование свойств с proptest ★★ (~25 минут)

Напишите обёртку SortedVec<T: Ord>, которая поддерживает инвариант сортировки. Используйте proptest, чтобы проверить, что:

  1. После любой последовательности вставок внутренний вектор всегда отсортирован
  2. contains() даёт тот же результат, что и Vec::contains() из стандартной библиотеки
  3. Длина равна количеству вставок
🔑 Решение
#[derive(Debug)]
struct SortedVec<T: Ord> {
    inner: Vec<T>,
}

impl<T: Ord> SortedVec<T> {
    fn new() -> Self { SortedVec { inner: Vec::new() } }

    fn insert(&mut self, value: T) {
        let pos = self.inner.binary_search(&value).unwrap_or_else(|p| p);
        self.inner.insert(pos, value);
    }

    fn contains(&self, value: &T) -> bool {
        self.inner.binary_search(value).is_ok()
    }

    fn len(&self) -> usize { self.inner.len() }
    fn as_slice(&self) -> &[T] { &self.inner }
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    proptest! {
        #[test]
        fn always_sorted(values in proptest::collection::vec(-1000i32..1000, 0..100)) {
            let mut sv = SortedVec::new();
            for v in &values {
                sv.insert(*v);
            }
            for w in sv.as_slice().windows(2) {
                prop_assert!(w[0] <= w[1]);
            }
            prop_assert_eq!(sv.len(), values.len());
        }

        #[test]
        fn contains_matches_stdlib(values in proptest::collection::vec(0i32..50, 1..30)) {
            let mut sv = SortedVec::new();
            for v in &values {
                sv.insert(*v);
            }
            for v in &values {
                prop_assert!(sv.contains(v));
            }
            prop_assert!(!sv.contains(&9999));
        }
    }
}

15. Архитектура крейтов и дизайн API 🟡

Что вы узнаете:

  • Соглашения о структуре модулей и стратегии реэкспорта
  • Чек-лист проектирования публичного API для аккуратных крейтов
  • Эргономичные паттерны параметров: impl Into, AsRef, Cow
  • «Parse, don’t validate» с TryFrom и проверенными типами
  • Флаги функций, условную компиляцию и организацию воркспейсов

Соглашения о структуре модулей

my_crate/
├── Cargo.toml
├── src/
│   ├── lib.rs          # Корень крейта: реэкспорты и публичный API
│   ├── config.rs       # Модуль функциональности
│   ├── parser/         # Сложный модуль с подмодулями
│   │   ├── mod.rs      # или parser.rs на уровне родителя (Rust 2018+)
│   │   ├── lexer.rs
│   │   └── ast.rs
│   ├── error.rs        # Типы ошибок
│   └── utils.rs        # Внутренние утилиты (pub(crate))
├── tests/
│   └── integration.rs  # Интеграционные тесты
├── benches/
│   └── perf.rs         # Бенчмарки
└── examples/
    └── basic.rs        # cargo run --example basic
#![allow(unused)]
fn main() {
// lib.rs: курируйте публичный API через реэкспорты:
mod config;
mod error;
mod parser;
mod utils;

// Реэкспортируем то, что нужно пользователям:
pub use config::Config;
pub use error::Error;
pub use parser::Parser;

// Публичные типы находятся в корне крейта, пользователи пишут:
// use my_crate::Config;
// НЕ так: use my_crate::config::Config;
}

Модификаторы видимости:

МодификаторВидим для
pubВсех
pub(crate)Только этого крейта
pub(super)Родительского модуля
pub(in path)Конкретного модуля-предка
(нет)Текущего модуля и его потомков

Чек-лист проектирования публичного API

  1. Принимайте ссылки, возвращайте владеющие значения: fn process(input: &str) -> String
  2. Используйте impl Trait для параметров: fn read(r: impl Read) вместо fn read<R: Read>(r: R) для более чистых сигнатур
  3. Возвращайте Result, а не panic!: пусть вызывающий код решает, как обрабатывать ошибки
  4. Реализуйте стандартные трейты: Debug, Display, Clone, Default, From/Into
  5. Делайте недопустимые состояния невыразимыми: используйте type-state и newtype
  6. Используйте паттерн builder для сложной конфигурации: с type-state, если поля обязательны
  7. Запечатывайте трейты, которые не должны реализовывать пользователи: pub trait Sealed: private::Sealed {}
  8. Помечайте типы и функции #[must_use]: это предотвращает молчаливое игнорирование важных Result, защитников или значений. Применяйте к любому типу, где игнорирование возвращаемого значения почти наверняка является ошибкой:
    #![allow(unused)]
    fn main() {
    #[must_use = "немедленное уничтожение защитника сразу освобождает блокировку"]
    pub struct LockGuard<'a, T> { /* ... */ }
    
    #[must_use]
    pub fn validate(input: &str) -> Result<ValidInput, ValidationError> { /* ... */ }
    }
#![allow(unused)]
fn main() {
// Паттерн sealed-трейта: пользователи могут использовать, но не реализовать:
mod private {
    pub trait Sealed {}
}

pub trait DatabaseDriver: private::Sealed {
    fn connect(&self, url: &str) -> Connection;
}

// Только типы ЭТОГО крейта могут реализовать Sealed, значит, только мы можем реализовать DatabaseDriver
pub struct PostgresDriver;
impl private::Sealed for PostgresDriver {}
impl DatabaseDriver for PostgresDriver {
    fn connect(&self, url: &str) -> Connection { /* ... */ }
}
}

#[non_exhaustive]: помечайте публичные перечисления и структуры так, чтобы добавление вариантов или полей не было несовместимым изменением. Внешние крейты должны использовать шаблонную ветку (_ =>) в match и не могут создавать этот тип с помощью синтаксиса литерала структуры:

#![allow(unused)]
fn main() {
#[non_exhaustive]
pub enum DiagError {
    Timeout,
    HardwareFault,
    // Добавление нового варианта в будущем выпуске НЕ является несовместимым изменением semver.
}
}

Эргономичные паттерны параметров: impl Into, AsRef, Cow

Один из самых влиятельных паттернов API в Rust: принимать в параметрах функций наиболее общий тип, чтобы вызывающему коду не приходилось на каждом шагу писать повторяющиеся .to_string(), &*s или .as_ref(). Это версия принципа «будь либерален в том, что принимаешь», специфичная для Rust.

impl Into<T>: принимаем всё, что конвертируется

#![allow(unused)]
fn main() {
// ❌ Неудобно: вызывающие должны конвертировать вручную
fn connect(host: String, port: u16) -> Connection {
    // ...
}
connect("localhost".to_string(), 5432);  // Раздражающий .to_string()
connect(hostname.clone(), 5432);          // Лишнее клонирование, если уже есть String

// ✅ Удобно: принимаем всё, что конвертируется в String
fn connect(host: impl Into<String>, port: u16) -> Connection {
    let host = host.into();  // Конвертируем один раз, внутри функции
    // ...
}
connect("localhost", 5432);     // &str: без лишних усилий
connect(hostname, 5432);        // String: перемещаем, без клонирования
}

Это работает, потому что пара трейтов From/Into обеспечивает общие преобразования. Принимая impl Into<T>, вы говорите: «дайте мне всё, что умеет стать T».

AsRef<T>: заимствование как ссылка

AsRef<T> это заимствующий аналог Into<T>. Используйте его, когда нужно только читать данные, а не забирать владение:

#![allow(unused)]
fn main() {
use std::path::Path;

// ❌ Вынуждает вызывающих конвертировать в &Path
fn file_exists(path: &Path) -> bool {
    path.exists()
}
file_exists(Path::new("/tmp/test.txt"));  // Неудобно

// ✅ Принимаем всё, что может вести себя как &Path
fn file_exists(path: impl AsRef<Path>) -> bool {
    path.as_ref().exists()
}
file_exists("/tmp/test.txt");                    // &str ✅
file_exists(String::from("/tmp/test.txt"));      // String ✅
file_exists(Path::new("/tmp/test.txt"));         // &Path ✅
file_exists(PathBuf::from("/tmp/test.txt"));     // PathBuf ✅

// Тот же паттерн для строковых параметров:
fn log_message(msg: impl AsRef<str>) {
    println!("[LOG] {}", msg.as_ref());
}
log_message("hello");                    // &str ✅
log_message(String::from("hello"));      // String ✅
}

Cow<T>: клонирование при записи

Cow<'a, T> (Clone on Write) откладывает выделение памяти до момента, когда понадобится изменение. Он хранит либо заимствованный &T, либо владеющий T::Owned. Это идеально, когда большинство вызовов не требует изменения данных:

#![allow(unused)]
fn main() {
use std::borrow::Cow;

/// Нормализует диагностическое сообщение: выделяет память, только если нужны изменения.
fn normalize_message(msg: &str) -> Cow<'_, str> {
    if msg.contains('\t') || msg.contains('\r') {
        // Нужно выделить память: требуется изменить содержимое
        Cow::Owned(msg.replace('\t', "    ").replace('\r', ""))
    } else {
        // Без выделения памяти: просто заимствуем исходное
        Cow::Borrowed(msg)
    }
}

// Большинство сообщений проходят без выделения памяти:
let clean = normalize_message("Все тесты пройдены");        // Borrowed: бесплатно
let fixed = normalize_message("Ошибка:\tсбой\r\n");         // Owned: выделено

// Cow<str> реализует Deref<Target=str>, поэтому работает как &str:
println!("{}", clean);
println!("{}", fixed.to_uppercase());
}

Краткая справка: что выбрать

Нужно ли функции владеть данными внутри?
├── ДА → impl Into<T>
│         «Дайте мне всё, что может стать T»
└── НЕТ → Нужно ли только читать?
     ├── ДА → impl AsRef<T> или &T
     │         «Дайте мне всё, что можно заимствовать как &T»
     └── ВОЗМОЖНО (иногда нужно изменять?)
          └── Cow<'_, T>
              «Заимствуем, если возможно, клонируем, только когда это необходимо»
ПаттернВладениеВыделение памятиКогда использовать
&strЗаимствованиеНикогдаПростые строковые параметры
impl AsRef<str>ЗаимствованиеНикогдаПринимает String, &str и т. д.: только чтение
impl Into<String>ВладениеПри конвертацииПринимает &str и String: будет хранить или владеть
Cow<'_, str>ЛюбоеТолько при измененииОбработка, которая обычно не меняет данные
&[u8] / impl AsRef<[u8]>ЗаимствованиеНикогдаAPI для работы с байтами

Borrow<T> против AsRef<T>: оба дают &T, но Borrow<T> дополнительно гарантирует, что Eq, Ord и Hash согласованы между исходной и заимствованной формой. Поэтому HashMap<String, V>::get() принимает &Q при условии String: Borrow<Q>, а не AsRef. Используйте Borrow, когда заимствованная форма применяется как ключ поиска, а AsRef для общих параметров «дайте мне ссылку».

Композиция преобразований в API

#![allow(unused)]
fn main() {
/// Хорошо спроектированный API для диагностики с эргономичными параметрами:
pub struct DiagRunner {
    name: String,
    config_path: PathBuf,
    results: HashMap<String, TestResult>,
}

impl DiagRunner {
    /// Принимаем любой строковый тип для name и любой путь для config.
    pub fn new(
        name: impl Into<String>,
        config_path: impl Into<PathBuf>,
    ) -> Self {
        DiagRunner {
            name: name.into(),
            config_path: config_path.into(),
        }
    }

    /// Принимаем любой AsRef<str> для поиска только для чтения.
    pub fn get_result(&self, test_name: impl AsRef<str>) -> Option<&TestResult> {
        self.results.get(test_name.as_ref())
    }
}

// Все эти вызовы работают без лишних усилий со стороны вызывающего кода:
let runner = DiagRunner::new("GPU Diag", "/etc/diag_tool/config.json");
let runner = DiagRunner::new(format!("Diag-{}", node_id), config_path);
let runner = DiagRunner::new(name_string, path_buf);
}

Пример из практики: проектирование публичного API крейта, до и после

Реальный пример перехода от внутреннего API, построенного на строках («строковый» API, stringly-typed), к эргономичному типобезопасному публичному API. Рассмотрим крейт-парсер конфигурации:

До (строки повсюду, легко ошибиться):

#![allow(unused)]
fn main() {
// ❌ Все параметры — строки: нет проверки на этапе компиляции
pub fn parse_config(path: &str, format: &str, strict: bool) -> Result<Config, String> {
    // Какие форматы допустимы? "json"? "JSON"? "Json"?
    // path — это путь к файлу или URL?
    // Что вообще значит "strict"?
    todo!()
}
}

После (типобезопасно, самодокументируется):

#![allow(unused)]
fn main() {
use std::path::Path;

/// Поддерживаемые форматы конфигурации.
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]  // Добавление форматов не сломает внешний код
pub enum Format {
    Json,
    Toml,
    Yaml,
}

/// Управляет строгостью разбора.
#[derive(Debug, Clone, Copy, Default)]
pub enum Strictness {
    /// Отвергать неизвестные поля (по умолчанию для библиотек)
    #[default]
    Strict,
    /// Игнорировать неизвестные поля (полезно для конфигов с прямой совместимостью)
    Lenient,
}

pub fn parse_config(
    path: &Path,          // Проверяется типом: должен быть путём файловой системы
    format: Format,       // Перечисление: невозможно передать неверный формат
    strictness: Strictness,  // Именованные варианты, а не голый bool
) -> Result<Config, ConfigError> {
    todo!()
}
}

Что улучшилось:

АспектДоПосле
Проверка форматаСравнение строк во время выполненияПеречисление на этапе компиляции
Тип путиСырой &str (что угодно)&Path (специфичный для файловой системы)
СтрогостьЗагадочный boolСамодокументируемое перечисление
Тип ошибкиString (непрозрачный)ConfigError (структурированный)
РасширяемостьНесовместимые изменения#[non_exhaustive]

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


Parse Don’t Validate: TryFrom и проверенные типы

«Parse, don’t validate» (разбирай, а не проверяй) это принцип, который говорит: не проверяйте данные и затем не передавайте дальше непроверенную сырую форму. Вместо этого разберите их в тип, который может существовать только тогда, когда данные корректны. Стандартный инструмент Rust для этого трейт TryFrom.

Проблема: проверка без принуждения

#![allow(unused)]
fn main() {
// ❌ Проверяем, а потом используем: ничто не мешает использовать некорректное значение после проверки
fn process_port(port: u16) {
    if port == 0 || port > 65535 {
        panic!("Некорректный порт");      // Мы проверили, но...
    }
    start_server(port);                    // Что если кто-то вызовет start_server(0) напрямую?
}

// ❌ Строка на все случаи: email это просто String, любой мусор проходит
fn send_email(to: String, body: String) {
    // Действительно ли `to` корректный email? Мы не знаем.
    // Кто-то может передать "not-an-email", и мы узнаем об этом только на SMTP-сервере.
}
}

Решение: разбор в проверенные newtype через TryFrom

use std::convert::TryFrom;
use std::fmt;

/// Проверенный номер TCP-порта (1–65535).
/// Если у вас есть `Port`, он гарантированно корректен.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Port(u16);

impl TryFrom<u16> for Port {
    type Error = PortError;

    fn try_from(value: u16) -> Result<Self, Self::Error> {
        if value == 0 {
            Err(PortError::Zero)
        } else {
            Ok(Port(value))
        }
    }
}

impl Port {
    pub fn get(&self) -> u16 { self.0 }
}

#[derive(Debug)]
pub enum PortError {
    Zero,
    InvalidFormat,
}

impl fmt::Display for PortError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            PortError::Zero => write!(f, "порт должен быть ненулевым"),
            PortError::InvalidFormat => write!(f, "некорректный формат порта"),
        }
    }
}

impl std::error::Error for PortError {}

// Теперь система типов обеспечивает корректность:
fn start_server(port: Port) {
    // Проверка не нужна: Port можно создать только через TryFrom,
    // который уже проверил его корректность.
    println!("Слушаем порт {}", port.get());
}

// Использование:
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let port = Port::try_from(8080)?;   // ✅ Проверяем один раз на границе
    start_server(port);                  // Никакой повторной проверки ниже по цепочке

    let bad = Port::try_from(0);         // ❌ Err(PortError::Zero)
    Ok(())
}

Пример из практики: проверенный адрес IPMI

#![allow(unused)]
fn main() {
/// Проверенный адрес ведомого устройства IPMI (0x20–0xFE, только чётные).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IpmiAddr(u8);

#[derive(Debug)]
pub enum IpmiAddrError {
    Odd(u8),
    OutOfRange(u8),
}

impl fmt::Display for IpmiAddrError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            IpmiAddrError::Odd(v) => write!(f, "адрес IPMI 0x{v:02X} должен быть чётным"),
            IpmiAddrError::OutOfRange(v) => {
                write!(f, "адрес IPMI 0x{v:02X} вне диапазона (0x20..=0xFE)")
            }
        }
    }
}

impl TryFrom<u8> for IpmiAddr {
    type Error = IpmiAddrError;

    fn try_from(value: u8) -> Result<Self, Self::Error> {
        if value % 2 != 0 {
            Err(IpmiAddrError::Odd(value))
        } else if value < 0x20 || value > 0xFE {
            Err(IpmiAddrError::OutOfRange(value))
        } else {
            Ok(IpmiAddr(value))
        }
    }
}

impl IpmiAddr {
    pub fn get(&self) -> u8 { self.0 }
}

// Код ниже по цепочке никогда не нужно проверять заново:
fn send_ipmi_command(addr: IpmiAddr, cmd: u8, data: &[u8]) -> Result<Vec<u8>, IpmiError> {
    // addr.get() гарантированно является корректным чётным адресом IPMI
    raw_ipmi_send(addr.get(), cmd, data)
}
}

Разбор строк через FromStr

Для типов, которые часто разбираются из текста (аргументы CLI, файлы конфигурации), реализуйте FromStr:

#![allow(unused)]
fn main() {
use std::str::FromStr;

impl FromStr for Port {
    type Err = PortError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let n: u16 = s.parse().map_err(|_| PortError::InvalidFormat)?;
        Port::try_from(n)
    }
}

// Теперь работает с .parse():
let port: Port = "8080".parse()?;   // Проверяется за один шаг

// И с разбором аргументов clap:
// #[derive(Parser)]
// struct Args {
//     #[arg(short, long)]
//     port: Port,   // clap вызывает FromStr автоматически
// }
}

Цепочка TryFrom для сложной проверки

#![allow(unused)]
fn main() {
// Заглушки типов для этого примера. В продакшене они были бы в
// отдельных модулях со своими реализациями TryFrom.
}
#![allow(unused)]
fn main() {
struct Hostname(String);
impl TryFrom<String> for Hostname {
    type Error = String;
    fn try_from(s: String) -> Result<Self, String> { Ok(Hostname(s)) }
}
struct Timeout(u64);
impl TryFrom<u64> for Timeout {
    type Error = String;
    fn try_from(ms: u64) -> Result<Self, String> {
        if ms == 0 { Err("таймаут должен быть > 0".into()) } else { Ok(Timeout(ms)) }
    }
}
struct RawConfig { host: String, port: u16, timeout_ms: u64 }
#[derive(Debug)]
enum ConfigError {
    InvalidHost(String),
    InvalidPort(PortError),
    InvalidTimeout(String),
}
impl From<std::io::Error> for ConfigError {
    fn from(e: std::io::Error) -> Self { ConfigError::InvalidHost(e.to_string()) }
}
impl From<serde_json::Error> for ConfigError {
    fn from(e: serde_json::Error) -> Self { ConfigError::InvalidHost(e.to_string()) }
}
/// Проверенная конфигурация, которая может существовать, только если все поля корректны.
pub struct ValidConfig {
    pub host: Hostname,
    pub port: Port,
    pub timeout_ms: Timeout,
}

impl TryFrom<RawConfig> for ValidConfig {
    type Error = ConfigError;

    fn try_from(raw: RawConfig) -> Result<Self, Self::Error> {
        Ok(ValidConfig {
            host: Hostname::try_from(raw.host)
                .map_err(ConfigError::InvalidHost)?,
            port: Port::try_from(raw.port)
                .map_err(ConfigError::InvalidPort)?,
            timeout_ms: Timeout::try_from(raw.timeout_ms)
                .map_err(ConfigError::InvalidTimeout)?,
        })
    }
}

// Разбираем один раз на границе, используем проверенный тип везде:
fn load_config(path: &str) -> Result<ValidConfig, ConfigError> {
    let raw: RawConfig = serde_json::from_str(&std::fs::read_to_string(path)?)?;
    ValidConfig::try_from(raw)  // Вся проверка происходит здесь
}
}

Итоги: проверка против разбора

ПодходДанные проверены?Компилятор следит за корректностью?Нужна повторная проверка?
Проверки во время выполнения (if/assert)✅❌На каждой границе функции
Проверенный newtype + TryFrom✅✅Никогда: тип является доказательством

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

Флаги функций и условная компиляция

# Cargo.toml
[features]
default = ["json"]          # Включена по умолчанию
json = ["dep:serde_json"]   # Включает поддержку JSON
xml = ["dep:quick-xml"]     # Включает поддержку XML
full = ["json", "xml"]      # Мета-фича: включает всё

[dependencies]
serde = "1"
serde_json = { version = "1", optional = true }
quick-xml = { version = "0.31", optional = true }
#![allow(unused)]
fn main() {
// Условная компиляция в зависимости от фич:
#[cfg(feature = "json")]
pub fn to_json<T: serde::Serialize>(value: &T) -> String {
    serde_json::to_string(value).unwrap()
}

#[cfg(feature = "xml")]
pub fn to_xml<T: serde::Serialize>(value: &T) -> String {
    quick_xml::se::to_string(value).unwrap()
}

// Ошибка компиляции, если нужная фича не включена:
#[cfg(not(any(feature = "json", feature = "xml")))]
compile_error!("Должна быть включена хотя бы одна фича формата (json, xml)");
}

Лучшие практики:

  • Держите фичи в default минимальными: пользователи сами включат то, что им нужно
  • Используйте синтаксис dep: (Rust 1.60+) для необязательных зависимостей, чтобы не создавать неявные фичи
  • Документируйте фичи в README и в документации крейта

Организация воркспейса

Для больших проектов используйте воркспейс Cargo, чтобы разделять зависимости и артефакты сборки:

# Корневой Cargo.toml
[workspace]
members = [
    "core",         # Общие типы и трейты
    "parser",       # Библиотека разбора
    "server",       # Бинарный файл: основное приложение
    "client",       # Клиентская библиотека
    "cli",          # Бинарный файл CLI
]

# Общие версии зависимостей:
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
tracing = "0.1"

# В Cargo.toml каждого участника:
# [dependencies]
# serde = { workspace = true }

Преимущества:

  • Один Cargo.lock: все крейты используют одни и те же версии зависимостей
  • cargo test --workspace запускает все тесты
  • Общий кэш сборки: компиляция одного крейта ускоряет остальные
  • Чистые границы зависимостей между компонентами

.cargo/config.toml: конфигурация на уровне проекта

Файл .cargo/config.toml (в корне воркспейса или в $HOME/.cargo/) настраивает поведение Cargo без изменения Cargo.toml:

# .cargo/config.toml

# Цель сборки по умолчанию для этого воркспейса
[build]
target = "x86_64-unknown-linux-gnu"

# Пользовательский runner: например, запуск через QEMU для кросс-компилированных бинарников
[target.aarch64-unknown-linux-gnu]
runner = "qemu-aarch64-static"
linker = "aarch64-linux-gnu-gcc"

# Псевдонимы Cargo: пользовательские короткие команды
[alias]
xt = "test --workspace --release"        # cargo xt = запустить все тесты в release
ci = "clippy --workspace -- -D warnings" # cargo ci = линтинг с ошибками на предупреждениях
cov = "llvm-cov --workspace"             # cargo cov = покрытие (требует cargo-llvm-cov)

# Переменные окружения для build-скриптов
[env]
IPMI_LIB_PATH = "/usr/lib/bmc"

# Использовать пользовательский реестр (для внутренних пакетов)
# [registries.internal]
# index = "https://gitlab.internal/crates/index"

Типичные настройки:

НастройкаНазначениеПример
[build] targetЦель компиляции по умолчаниюx86_64-unknown-linux-musl для статических сборок
[target.X] runnerКак запускать бинарный файл"qemu-aarch64-static" для кросс-компилированных
[target.X] linkerКакой линкер использовать"aarch64-linux-gnu-gcc"
[alias]Пользовательские подкоманды cargoxt = "test --workspace"
[env]Переменные окружения на этапе сборкиПути к библиотекам, переключатели фич
[net] offlineЗапрет доступа к сетиtrue для сборок в изолированной среде

Переменные окружения на этапе компиляции: env!() и option_env!()

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

#![allow(unused)]
fn main() {
// env!() паникует на этапе компиляции, если переменной нет
const VERSION: &str = env!("CARGO_PKG_VERSION"); // "0.1.0" из Cargo.toml
const PKG_NAME: &str = env!("CARGO_PKG_NAME");   // Имя крейта из Cargo.toml

// option_env!() возвращает Option<&str> и не паникует, если переменной нет
const BUILD_SHA: Option<&str> = option_env!("GIT_SHA");
const BUILD_TIME: Option<&str> = option_env!("BUILD_TIMESTAMP");

fn print_version() {
    println!("{PKG_NAME} v{VERSION}");
    if let Some(sha) = BUILD_SHA {
        println!("  коммит: {sha}");
    }
    if let Some(time) = BUILD_TIME {
        println!("  собран: {time}");
    }
}
}

Cargo автоматически задаёт множество полезных переменных окружения:

ПеременнаяЗначениеСценарий использования
CARGO_PKG_VERSION"1.2.3"Вывод версии
CARGO_PKG_NAME"diag_tool"Идентификация бинарного файла
CARGO_PKG_AUTHORSИз Cargo.tomlТекст «о программе» и справки
CARGO_MANIFEST_DIRАбсолютный путь к Cargo.tomlПоиск файлов тестовых данных
OUT_DIRКаталог выходных данных сборкиЦель для генерации кода в build.rs
TARGETTarget tripleЛогика, специфичная для платформы, в build.rs

Свои переменные окружения можно задать из build.rs:

// build.rs
fn main() {
    println!("cargo::rustc-env=GIT_SHA={}", git_sha());
    println!("cargo::rustc-env=BUILD_TIMESTAMP={}", timestamp());
}

cfg_attr: условные атрибуты

cfg_attr применяет атрибут только тогда, когда условие истинно. Это точечнее, чем #[cfg()], который включает или исключает целые элементы:

#![allow(unused)]
fn main() {
// Derive Serialize только когда включена фича "serde":
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone)]
pub struct DiagResult {
    pub fc: u32,
    pub passed: bool,
    pub message: String,
}
// Без фичи "serde": зависимость от serde вообще не нужна
// С фичей "serde": DiagResult можно сериализовать

// Условный атрибут для тестов:
#[cfg_attr(test, derive(PartialEq))]  // Derive PartialEq только в тестовых сборках
pub struct LargeStruct { /* ... */ }

// Атрибуты функций, зависящие от платформы:
#[cfg_attr(target_os = "linux", link_name = "ioctl")]
#[cfg_attr(target_os = "freebsd", link_name = "__ioctl")]
extern "C" fn platform_ioctl(fd: i32, request: u64) -> i32;
}
ПаттернЧто делает
#[cfg(feature = "x")]Включает или исключает весь элемент
#[cfg_attr(feature = "x", derive(Foo))]Добавляет derive(Foo) только при включённой фиче «x»
#[cfg_attr(test, allow(unused))]Подавляет предупреждения только в тестовых сборках
#[cfg_attr(doc, doc = "...")]Документация видна только в cargo doc

cargo deny и cargo audit: безопасность цепочки поставок

# Установка инструментов безопасности
cargo install cargo-deny
cargo install cargo-audit

# Проверка известных уязвимостей в зависимостях
cargo audit

# Комплексные проверки: лицензии, запреты, уязвимости, источники
cargo deny check

Настройте cargo deny с помощью deny.toml в корне воркспейса:

# deny.toml
[advisories]
vulnerability = "deny"      # Завершать с ошибкой при известных уязвимостях
unmaintained = "warn"        # Предупреждать о неподдерживаемых крейтах

[licenses]
allow = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause"]
deny = ["GPL-3.0"]          # Отвергать копилефт-лицензии

[bans]
multiple-versions = "warn"  # Предупреждать, если есть несколько версий одного крейта
deny = [
    { name = "openssl" },   # Принудительно использовать rustls вместо неё
]

[sources]
allow-git = []              # Никаких git-зависимостей в продакшене
ИнструментНазначениеКогда запускать
cargo auditПроверка известных CVE в зависимостяхCI-конвейер, перед выпуском
cargo deny checkЛицензии, запреты, уязвимости, источникиCI-конвейер
cargo deny check licensesТолько соответствие лицензиямПеред открытием исходного кода
cargo deny check bansЗапрет конкретных крейтовСоблюдение архитектурных решений

Doc-тесты: тесты внутри документации

Комментарии документации Rust (///) могут содержать блоки кода, которые компилируются и запускаются как тесты:

#![allow(unused)]
fn main() {
/// Разбирает код неисправности диагностики из строки.
///
/// # Примеры
///
/// ```
/// use my_crate::parse_fc;
///
/// let fc = parse_fc("FC:12345").unwrap();
/// assert_eq!(fc, 12345);
/// ```
///
/// Некорректный ввод возвращает ошибку:
///
/// ```
/// use my_crate::parse_fc;
///
/// assert!(parse_fc("not-a-fc").is_err());
/// ```
pub fn parse_fc(input: &str) -> Result<u32, ParseError> {
    input.strip_prefix("FC:")
        .ok_or(ParseError::MissingPrefix)?
        .parse()
        .map_err(ParseError::InvalidNumber)
}
}
cargo test --doc  # Запустить только doc-тесты
cargo test        # Запускает модульные, интеграционные и doc-тесты

Документация уровня модуля пишется с помощью //! в начале файла:

#![allow(unused)]
fn main() {
//! # Фреймворк диагностики
//!
//! Этот крейт предоставляет ядро движка выполнения диагностики.
//! Он запускает диагностические тесты, собирает результаты
//! и передаёт их в BMC через IPMI.
//!
//! ## Быстрый старт
//!
//! ```no_run
//! use diag_framework::Framework;
//!
//! let mut fw = Framework::new("config.json")?;
//! fw.run_all_tests()?;
//! ```
}

Бенчмаркинг с criterion

Полное описание: см. раздел Бенчмаркинг с criterion в главе 14 (Паттерны тестирования и бенчмаркинга). Там полная настройка criterion, примеры API и сравнительная таблица с cargo bench. Ниже приведена краткая справка для использования, специфичного для архитектуры.

При бенчмаркинге публичного API крейта помещайте бенчмарки в benches/ и держите их сфокусированными на горячем пути: обычно это парсеры, сериализаторы или границы валидации:

cargo bench                  # Запустить все бенчмарки
cargo bench -- parse_config  # Запустить конкретный бенчмарк
# Результаты в target/criterion/ с HTML-отчётами

Ключевые выводы: архитектура и дизайн API

  • Принимайте самый общий тип (impl Into, impl AsRef, Cow), а возвращайте самый конкретный
  • Parse, don’t validate: используйте TryFrom, чтобы создавать типы, корректные по построению
  • #[non_exhaustive] на публичных перечислениях предотвращает несовместимые изменения при добавлении вариантов
  • #[must_use] ловит молчаливое игнорирование важных значений

См. также: гл. 10 — Обработка ошибок о проектировании типов ошибок в публичных API. гл. 14 — Тестирование о тестировании публичного API крейта.


Упражнение: рефакторинг API крейта ★★ (~30 минут)

Перепишите следующий «строковый» API в вариант, который использует TryFrom, newtype и паттерн builder:

// ДО: легко ошибиться
fn create_server(host: &str, port: &str, max_conn: &str) -> Server { ... }

Спроектируйте ServerConfig с проверенными типами Host, Port (1–65535) и MaxConnections (1–10000), которые отвергают некорректные значения на этапе разбора.

🔑 Решение
#[derive(Debug, Clone)]
struct Host(String);

impl TryFrom<&str> for Host {
    type Error = String;
    fn try_from(s: &str) -> Result<Self, String> {
        if s.is_empty() { return Err("имя хоста не может быть пустым".into()); }
        if s.contains(' ') { return Err("имя хоста не может содержать пробелы".into()); }
        Ok(Host(s.to_string()))
    }
}

#[derive(Debug, Clone, Copy)]
struct Port(u16);

impl TryFrom<u16> for Port {
    type Error = String;
    fn try_from(p: u16) -> Result<Self, String> {
        if p == 0 { return Err("порт должен быть >= 1".into()); }
        Ok(Port(p))
    }
}

#[derive(Debug, Clone, Copy)]
struct MaxConnections(u32);

impl TryFrom<u32> for MaxConnections {
    type Error = String;
    fn try_from(n: u32) -> Result<Self, String> {
        if n == 0 || n > 10_000 {
            return Err(format!("max_connections должен быть в диапазоне 1–10000, получено {n}"));
        }
        Ok(MaxConnections(n))
    }
}

#[derive(Debug)]
struct ServerConfig {
    host: Host,
    port: Port,
    max_connections: MaxConnections,
}

impl ServerConfig {
    fn new(host: Host, port: Port, max_connections: MaxConnections) -> Self {
        ServerConfig { host, port, max_connections }
    }
}

fn main() {
    let config = ServerConfig::new(
        Host::try_from("localhost").unwrap(),
        Port::try_from(8080).unwrap(),
        MaxConnections::try_from(100).unwrap(),
    );
    println!("{config:?}");

    // Некорректные значения отлавливаются на этапе разбора:
    assert!(Host::try_from("").is_err());
    assert!(Port::try_from(0).is_err());
    assert!(MaxConnections::try_from(99999).is_err());
}

16. Основы async/await 🔴

Что вы узнаете:

  • Чем трейт Future в Rust отличается от горутин Go и asyncio в Python
  • Быстрый старт с Tokio: запуск задач, join! и настройка рантайма
  • Распространённые ошибки в async-коде и способы их исправления
  • Когда выносить блокирующую работу в spawn_blocking

Future, рантаймы и async fn

Модель async в Rust принципиально отличается от горутин Go или asyncio в Python. Для начала достаточно понимать три концепции:

  1. Future это ленивый конечный автомат: вызов async fn ничего не выполняет, он возвращает Future, который нужно опросить (poll).
  2. Для опроса Future нужен рантайм: tokio, async-std или smol. Стандартная библиотека определяет Future, но рантайма не предоставляет.
  3. async fn это синтаксический сахар: компилятор преобразует её в конечный автомат, который реализует Future.
#![allow(unused)]
fn main() {
// Future — это просто трейт:
pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

// async fn раскрывается в:
// fn fetch_data(url: &str) -> impl Future<Output = Result<Vec<u8>, Error>>
async fn fetch_data(url: &str) -> Result<Vec<u8>, reqwest::Error> {
    let response = reqwest::get(url).await?;  // .await отдаёт управление, пока результат не готов
    let bytes = response.bytes().await?;
    Ok(bytes.to_vec())
}
}

Быстрый старт с Tokio

# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }
use tokio::time::{sleep, Duration};
use tokio::task;

#[tokio::main]
async fn main() {
    // Запускаем конкурентные задачи (как лёгкие потоки):
    let handle_a = task::spawn(async {
        sleep(Duration::from_millis(100)).await;
        "задача A выполнена"
    });

    let handle_b = task::spawn(async {
        sleep(Duration::from_millis(50)).await;
        "задача B выполнена"
    });

    // .await обе: они выполняются конкурентно, а не последовательно:
    let (a, b) = tokio::join!(handle_a, handle_b);
    println!("{}, {}", a.unwrap(), b.unwrap());
}

Распространённые ошибки в async

ОшибкаПочему возникаетРешение
Блокировка в asyncstd::thread::sleep или вычисления блокируют исполнительИспользуйте tokio::task::spawn_blocking или rayon
Ошибки границы SendFuture удерживает через .await значение !Send (например, Rc, MutexGuard)Перестройте код так, чтобы не-Send значения уничтожались до .await
Future не опрошенВызов async fn без .await или запуска: ничего не происходитВсегда делайте .await или tokio::spawn для возвращённого future
Удержание MutexGuard через .awaitstd::sync::MutexGuard это !Send; async-задача может продолжиться в другом потокеИспользуйте tokio::sync::Mutex или уничтожьте защитника до .await
Непреднамеренно последовательное выполнениеlet a = foo().await; let b = bar().await; выполняется последовательноИспользуйте tokio::join! или tokio::spawn для конкурентности
#![allow(unused)]
fn main() {
// ❌ Блокировка async-исполнителя:
async fn bad() {
    std::thread::sleep(std::time::Duration::from_secs(5)); // Блокирует весь поток!
}

// ✅ Выносим блокирующую работу:
async fn good() {
    tokio::task::spawn_blocking(|| {
        std::thread::sleep(std::time::Duration::from_secs(5)); // Выполняется в пуле для блокирующих задач
    }).await.unwrap();
}
}

Полное описание async: о Stream, select!, безопасности отмены, структурированной конкурентности и middleware tower см. в нашем отдельном руководстве Async Rust Training. Этот раздел охватывает лишь то, что нужно для чтения и написания базового async-кода.

Порождение задач и структурированная конкурентность

spawn в Tokio создаёт новую асинхронную задачу: это похоже на thread::spawn, но намного легче:

use tokio::task;
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    // Запускаем три конкурентные задачи
    let h1 = task::spawn(async {
        sleep(Duration::from_millis(200)).await;
        "профиль пользователя получен"
    });

    let h2 = task::spawn(async {
        sleep(Duration::from_millis(100)).await;
        "история заказов получена"
    });

    let h3 = task::spawn(async {
        sleep(Duration::from_millis(150)).await;
        "рекомендации получены"
    });

    // Ждём все три конкурентно (не последовательно!)
    let (r1, r2, r3) = tokio::join!(h1, h2, h3);
    println!("{}", r1.unwrap());
    println!("{}", r2.unwrap());
    println!("{}", r3.unwrap());
}

join! против try_join! против select!:

МакросПоведениеКогда использовать
join!Ждёт ВСЕ futureВсе задачи должны завершиться
try_join!Ждёт все, прерывается на первом ErrЗадачи возвращают Result
select!Возвращает управление, когда ПЕРВЫЙ future завершилсяТаймауты, отмена
use tokio::time::{timeout, Duration};

async fn fetch_with_timeout() -> Result<String, Box<dyn std::error::Error>> {
    let result = timeout(Duration::from_secs(5), async {
        // Имитация медленного сетевого вызова
        tokio::time::sleep(Duration::from_millis(100)).await;
        Ok::<_, Box<dyn std::error::Error>>("data".to_string())
    }).await??; // Первый ? разворачивает Elapsed, второй ? разворачивает внутренний Result

    Ok(result)
}

Send и почему future должны быть Send

Когда вы делаете tokio::spawn для future, он может продолжиться в другом потоке ОС. Поэтому future должен быть Send. Распространённые ошибки:

use std::rc::Rc;

async fn not_send() {
    let rc = Rc::new(42); // Rc является !Send
    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
    println!("{}", rc); // rc удерживается через .await: future является !Send
}

// Исправление 1: уничтожить до .await
async fn fixed_drop() {
    let data = {
        let rc = Rc::new(42);
        *rc // Копируем значение наружу
    }; // rc уничтожается здесь
    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
    println!("{}", data); // Просто i32, который является Send
}

// Исправление 2: использовать Arc вместо Rc
async fn fixed_arc() {
    let arc = std::sync::Arc::new(42); // Arc является Send
    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
    println!("{}", arc); // ✅ Future является Send
}

Полное описание async: о Stream, select!, безопасности отмены, структурированной конкурентности и middleware tower см. в нашем отдельном руководстве Async Rust Training. Этот раздел охватывает лишь то, что нужно для чтения и написания базового async-кода.

См. также: гл. 5 — Каналы о синхронных каналах. гл. 6 — Конкурентность о потоках ОС и async-задачах.

Ключевые выводы: async

  • async fn возвращает ленивый Future: ничего не выполняется, пока вы не сделаете .await или не запустите его
  • Используйте tokio::task::spawn_blocking для тяжёлых вычислений или блокирующей работы внутри async-контекста
  • Не держите std::sync::MutexGuard через .await: используйте tokio::sync::Mutex
  • Future должны быть Send при запуске: уничтожайте !Send-значения до точек .await

Упражнение: конкурентный загрузчик с таймаутом ★★ (~25 минут)

Напишите асинхронную функцию fetch_all, которая запускает три задачи tokio::spawn, каждая из которых имитирует сетевой вызов через tokio::time::sleep. Объедините все три через tokio::try_join!, обёрнутый в tokio::time::timeout(Duration::from_secs(5), ...). Верните Result<Vec<String>, ...> или ошибку, если какая-либо задача завершилась неудачно или истёк срок.

🔑 Решение
use tokio::time::{sleep, timeout, Duration};

async fn fake_fetch(name: &'static str, delay_ms: u64) -> Result<String, String> {
    sleep(Duration::from_millis(delay_ms)).await;
    Ok(format!("{name}: OK"))
}

async fn fetch_all() -> Result<Vec<String>, Box<dyn std::error::Error>> {
    let deadline = Duration::from_secs(5);

    let (a, b, c) = timeout(deadline, async {
        let h1 = tokio::spawn(fake_fetch("svc-a", 100));
        let h2 = tokio::spawn(fake_fetch("svc-b", 200));
        let h3 = tokio::spawn(fake_fetch("svc-c", 150));
        tokio::try_join!(h1, h2, h3)
    })
    .await??;

    Ok(vec![a?, b?, c?])
}

#[tokio::main]
async fn main() {
    let results = fetch_all().await.unwrap();
    for r in &results {
        println!("{r}");
    }
}

17. Упражнения

Упражнения

Упражнение 1: типобезопасный автомат состояний ★★ (~30 минут)

Постройте автомат состояний светофора с помощью паттерна type-state. Свет должен переходить Red → Green → Yellow → Red, и никакой другой порядок не должен быть возможен.

🔑 Решение
use std::marker::PhantomData;

struct Red;
struct Green;
struct Yellow;

struct TrafficLight<State> {
    _state: PhantomData<State>,
}

impl TrafficLight<Red> {
    fn new() -> Self {
        println!("🔴 Красный — СТОП");
        TrafficLight { _state: PhantomData }
    }

    fn go(self) -> TrafficLight<Green> {
        println!("🟢 Зелёный — ВПЕРЁД");
        TrafficLight { _state: PhantomData }
    }
}

impl TrafficLight<Green> {
    fn caution(self) -> TrafficLight<Yellow> {
        println!("🟡 Жёлтый — ВНИМАНИЕ");
        TrafficLight { _state: PhantomData }
    }
}

impl TrafficLight<Yellow> {
    fn stop(self) -> TrafficLight<Red> {
        println!("🔴 Красный — СТОП");
        TrafficLight { _state: PhantomData }
    }
}

fn main() {
    let light = TrafficLight::new(); // Красный
    let light = light.go();          // Зелёный
    let light = light.caution();     // Жёлтый
    let light = light.stop();        // Красный

    // light.caution(); // ❌ Ошибка компиляции: нет метода `caution` для Red
    // TrafficLight::new().stop(); // ❌ Ошибка компиляции: нет метода `stop` для Red
}

Ключевой вывод: недопустимые переходы это ошибки компиляции, а не паники во время выполнения.


Упражнение 2: единицы измерения с PhantomData ★★ (~30 минут)

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

  • Meters, Seconds, Kilograms
  • сложение одинаковых единиц
  • умножение: Meters * Meters = SquareMeters
  • деление: Meters / Seconds = MetersPerSecond
🔑 Решение
use std::marker::PhantomData;
use std::ops::{Add, Mul, Div};

#[derive(Clone, Copy)]
struct Meters;
#[derive(Clone, Copy)]
struct Seconds;
#[derive(Clone, Copy)]
struct Kilograms;
#[derive(Clone, Copy)]
struct SquareMeters;
#[derive(Clone, Copy)]
struct MetersPerSecond;

#[derive(Debug, Clone, Copy)]
struct Qty<U> {
    value: f64,
    _unit: PhantomData<U>,
}

impl<U> Qty<U> {
    fn new(v: f64) -> Self { Qty { value: v, _unit: PhantomData } }
}

impl<U> Add for Qty<U> {
    type Output = Qty<U>;
    fn add(self, rhs: Self) -> Self::Output { Qty::new(self.value + rhs.value) }
}

impl Mul<Qty<Meters>> for Qty<Meters> {
    type Output = Qty<SquareMeters>;
    fn mul(self, rhs: Qty<Meters>) -> Qty<SquareMeters> {
        Qty::new(self.value * rhs.value)
    }
}

impl Div<Qty<Seconds>> for Qty<Meters> {
    type Output = Qty<MetersPerSecond>;
    fn div(self, rhs: Qty<Seconds>) -> Qty<MetersPerSecond> {
        Qty::new(self.value / rhs.value)
    }
}

fn main() {
    let width = Qty::<Meters>::new(5.0);
    let height = Qty::<Meters>::new(3.0);
    let area = width * height; // Qty<SquareMeters>
    println!("Площадь: {:.1} м²", area.value);

    let dist = Qty::<Meters>::new(100.0);
    let time = Qty::<Seconds>::new(9.58);
    let speed = dist / time;
    println!("Скорость: {:.2} м/с", speed.value);

    let sum = width + height; // Одинаковые единицы ✅
    println!("Сумма: {:.1} м", sum.value);

    // let bad = width + time; // ❌ Ошибка компиляции: нельзя сложить Meters и Seconds
}

Упражнение 3: пул воркеров на каналах ★★★ (~45 минут)

Постройте пул воркеров на каналах, где:

  • диспетчер отправляет структуры Job через канал
  • N воркеров забирают задачи и отправляют результаты обратно
  • используйте crossbeam-channel (или std::sync::mpsc, если crossbeam недоступен)
🔑 Решение
use std::sync::mpsc;
use std::thread;

struct Job {
    id: u64,
    data: String,
}

struct JobResult {
    job_id: u64,
    output: String,
    worker_id: usize,
}

fn worker_pool(jobs: Vec<Job>, num_workers: usize) -> Vec<JobResult> {
    let (job_tx, job_rx) = mpsc::channel::<Job>();
    let (result_tx, result_rx) = mpsc::channel::<JobResult>();

    // Оборачиваем получатель в Arc<Mutex> для совместного использования воркерами
    let job_rx = std::sync::Arc::new(std::sync::Mutex::new(job_rx));

    // Запускаем воркеров
    let mut handles = Vec::new();
    for worker_id in 0..num_workers {
        let job_rx = job_rx.clone();
        let result_tx = result_tx.clone();
        handles.push(thread::spawn(move || {
            loop {
                // Блокировка, получение, разблокировка: короткая критическая секция
                let job = {
                    let rx = job_rx.lock().unwrap();
                    rx.recv() // Блокирует, пока не придёт задача или канал не закроется
                };
                match job {
                    Ok(job) => {
                        let output = format!("обработано '{}' воркером {worker_id}", job.data);
                        result_tx.send(JobResult {
                            job_id: job.id,
                            output,
                            worker_id,
                        }).unwrap();
                    }
                    Err(_) => break, // Канал закрыт: выходим
                }
            }
        }));
    }
    drop(result_tx); // Отбрасываем нашу копию, чтобы канал результатов закрылся, когда воркеры завершат работу

    // Раздаём задачи
    let num_jobs = jobs.len();
    for job in jobs {
        job_tx.send(job).unwrap();
    }
    drop(job_tx); // Закрываем канал задач: воркеры завершатся после обработки оставшихся

    // Собираем результаты
    let mut results = Vec::new();
    for result in result_rx {
        results.push(result);
    }
    assert_eq!(results.len(), num_jobs);

    for h in handles { h.join().unwrap(); }
    results
}

fn main() {
    let jobs: Vec<Job> = (0..20).map(|i| Job {
        id: i,
        data: format!("задача-{i}"),
    }).collect();

    let results = worker_pool(jobs, 4);
    for r in &results {
        println!("[воркер {}] задача {}: {}", r.worker_id, r.job_id, r.output);
    }
}

Упражнение 4: конвейер комбинаторов высшего порядка ★★ (~25 минут)

Создайте структуру Pipeline, которая связывает преобразования в цепочку. Она должна поддерживать .pipe(f) для добавления преобразования и .execute(input) для выполнения всей цепочки.

🔑 Решение
struct Pipeline<T> {
    transforms: Vec<Box<dyn Fn(T) -> T>>,
}

impl<T: 'static> Pipeline<T> {
    fn new() -> Self {
        Pipeline { transforms: Vec::new() }
    }

    fn pipe(mut self, f: impl Fn(T) -> T + 'static) -> Self {
        self.transforms.push(Box::new(f));
        self
    }

    fn execute(self, input: T) -> T {
        self.transforms.into_iter().fold(input, |val, f| f(val))
    }
}

fn main() {
    let result = Pipeline::new()
        .pipe(|s: String| s.trim().to_string())
        .pipe(|s| s.to_uppercase())
        .pipe(|s| format!(">>> {s} <<<"))
        .execute("  hello world  ".to_string());

    println!("{result}"); // >>> HELLO WORLD <<<

    // Числовой конвейер:
    let result = Pipeline::new()
        .pipe(|x: i32| x * 2)
        .pipe(|x| x + 10)
        .pipe(|x| x * x)
        .execute(5);

    println!("{result}"); // (5*2 + 10)^2 = 400
}

Бонус: обобщённый конвейер, который меняет тип между этапами, потребовал бы другой конструкции: каждый .pipe() возвращал бы Pipeline с другим выходным типом (это требует более продвинутой обобщённой «обвязки»).


Упражнение 5: иерархия ошибок на thiserror ★★ (~30 минут)

Спроектируйте иерархию типов ошибок для приложения обработки файлов, которое может завершиться ошибкой при вводе-выводе, разборе (JSON и CSV) или валидации. Используйте thiserror и продемонстрируйте распространение ошибок через ?.

🔑 Решение
use thiserror::Error;

#[derive(Error, Debug)]
pub enum AppError {
    #[error("ошибка ввода-вывода: {0}")]
    Io(#[from] std::io::Error),

    #[error("ошибка разбора JSON: {0}")]
    Json(#[from] serde_json::Error),

    #[error("ошибка CSV в строке {line}: {message}")]
    Csv { line: usize, message: String },

    #[error("ошибка валидации: {field} — {reason}")]
    Validation { field: String, reason: String },
}

fn read_file(path: &str) -> Result<String, AppError> {
    Ok(std::fs::read_to_string(path)?) // io::Error → AppError::Io через #[from]
}

fn parse_json(content: &str) -> Result<serde_json::Value, AppError> {
    Ok(serde_json::from_str(content)?) // serde_json::Error → AppError::Json
}

fn validate_name(value: &serde_json::Value) -> Result<String, AppError> {
    let name = value.get("name")
        .and_then(|v| v.as_str())
        .ok_or_else(|| AppError::Validation {
            field: "name".into(),
            reason: "должно быть строкой, а не null".into(),
        })?;

    if name.is_empty() {
        return Err(AppError::Validation {
            field: "name".into(),
            reason: "не должно быть пустым".into(),
        });
    }

    Ok(name.to_string())
}

fn process_file(path: &str) -> Result<String, AppError> {
    let content = read_file(path)?;
    let json = parse_json(&content)?;
    let name = validate_name(&json)?;
    Ok(name)
}

fn main() {
    match process_file("config.json") {
        Ok(name) => println!("Имя: {name}"),
        Err(e) => eprintln!("Ошибка: {e}"),
    }
}

Упражнение 6: обобщённый трейт с ассоциированными типами ★★★ (~40 минут)

Спроектируйте трейт Repository<T> с ассоциированными типами Error и Id. Реализуйте его для хранилища в памяти и продемонстрируйте безопасность типов на этапе компиляции.

🔑 Решение
use std::collections::HashMap;

trait Repository {
    type Item;
    type Id;
    type Error;

    fn get(&self, id: &Self::Id) -> Result<Option<&Self::Item>, Self::Error>;
    fn insert(&mut self, item: Self::Item) -> Result<Self::Id, Self::Error>;
    fn delete(&mut self, id: &Self::Id) -> Result<bool, Self::Error>;
}

#[derive(Debug, Clone)]
struct User {
    name: String,
    email: String,
}

struct InMemoryUserRepo {
    data: HashMap<u64, User>,
    next_id: u64,
}

impl InMemoryUserRepo {
    fn new() -> Self {
        InMemoryUserRepo { data: HashMap::new(), next_id: 1 }
    }
}

// Тип ошибки Infallible: операции в памяти никогда не падают
impl Repository for InMemoryUserRepo {
    type Item = User;
    type Id = u64;
    type Error = std::convert::Infallible;

    fn get(&self, id: &u64) -> Result<Option<&User>, Self::Error> {
        Ok(self.data.get(id))
    }

    fn insert(&mut self, item: User) -> Result<u64, Self::Error> {
        let id = self.next_id;
        self.next_id += 1;
        self.data.insert(id, item);
        Ok(id)
    }

    fn delete(&mut self, id: &u64) -> Result<bool, Self::Error> {
        Ok(self.data.remove(id).is_some())
    }
}

// Обобщённая функция работает с ЛЮБЫМ репозиторием:
fn create_and_fetch<R: Repository>(repo: &mut R, item: R::Item) -> Result<(), R::Error>
where
    R::Item: std::fmt::Debug,
    R::Id: std::fmt::Debug,
{
    let id = repo.insert(item)?;
    println!("Вставлено с id: {id:?}");
    let retrieved = repo.get(&id)?;
    println!("Получено: {retrieved:?}");
    Ok(())
}

fn main() {
    let mut repo = InMemoryUserRepo::new();
    create_and_fetch(&mut repo, User {
        name: "Alice".into(),
        email: "alice@example.com".into(),
    }).unwrap();
}

Упражнение 7: безопасная обёртка над unsafe (гл. 12) ★★★ (~45 минут)

Напишите FixedVec<T, const N: usize>: вектор фиксированной ёмкости, размещённый на стеке.

Требования:

  • push(&mut self, value: T) -> Result<(), T> возвращает Err(value), когда вектор заполнен
  • pop(&mut self) -> Option<T> возвращает и удаляет последний элемент
  • as_slice(&self) -> &[T] заимствует инициализированные элементы
  • Все публичные методы должны быть безопасными; весь unsafe инкапсулирован с комментариями SAFETY:
  • Drop должен очищать инициализированные элементы

Подсказка: используйте MaybeUninit<T> и [const { MaybeUninit::uninit() }; N].

🔑 Решение
use std::mem::MaybeUninit;

pub struct FixedVec<T, const N: usize> {
    data: [MaybeUninit<T>; N],
    len: usize,
}

impl<T, const N: usize> FixedVec<T, N> {
    pub fn new() -> Self {
        FixedVec {
            data: [const { MaybeUninit::uninit() }; N],
            len: 0,
        }
    }

    pub fn push(&mut self, value: T) -> Result<(), T> {
        if self.len >= N { return Err(value); }
        // SAFETY: len < N, поэтому data[len] находится в пределах.
        self.data[self.len] = MaybeUninit::new(value);
        self.len += 1;
        Ok(())
    }

    pub fn pop(&mut self) -> Option<T> {
        if self.len == 0 { return None; }
        self.len -= 1;
        // SAFETY: data[len] был инициализирован (len был > 0 до уменьшения).
        Some(unsafe { self.data[self.len].assume_init_read() })
    }

    pub fn as_slice(&self) -> &[T] {
        // SAFETY: data[0..len] инициализированы, и MaybeUninit<T>
        // имеет ту же раскладку, что и T.
        unsafe { std::slice::from_raw_parts(self.data.as_ptr() as *const T, self.len) }
    }

    pub fn len(&self) -> usize { self.len }
    pub fn is_empty(&self) -> bool { self.len == 0 }
}

impl<T, const N: usize> Drop for FixedVec<T, N> {
    fn drop(&mut self) {
        // SAFETY: data[0..len] инициализированы: уничтожаем каждый.
        for i in 0..self.len {
            unsafe { self.data[i].assume_init_drop(); }
        }
    }
}

fn main() {
    let mut v = FixedVec::<String, 4>::new();
    v.push("hello".into()).unwrap();
    v.push("world".into()).unwrap();
    assert_eq!(v.as_slice(), &["hello", "world"]);
    assert_eq!(v.pop(), Some("world".into()));
    assert_eq!(v.len(), 1);
    // Drop очищает оставшийся элемент "hello"
}

Упражнение 8: декларативный макрос map! (гл. 13) ★ (~15 минут)

Напишите макрос map!, который создаёт HashMap из пар ключ-значение, аналогично vec![]:

#![allow(unused)]
fn main() {
let m = map! {
    "host" => "localhost",
    "port" => "8080",
};
assert_eq!(m.get("host"), Some(&"localhost"));
assert_eq!(m.len(), 2);
}

Требования:

  • Поддержка завершающей запятой
  • Поддержка пустого вызова map!{}
  • Работа с любыми типами, которые реализуют Into<K> и Into<V>, для максимальной гибкости
🔑 Решение
macro_rules! map {
    // Пустой случай
    () => {
        std::collections::HashMap::new()
    };
    // Одна или более пар ключ => значение (завершающая запятая необязательна)
    ( $( $key:expr => $val:expr ),+ $(,)? ) => {{
        let mut m = std::collections::HashMap::new();
        $( m.insert($key, $val); )+
        m
    }};
}

fn main() {
    // Базовое использование:
    let config = map! {
        "host" => "localhost",
        "port" => "8080",
        "timeout" => "30",
    };
    assert_eq!(config.len(), 3);
    assert_eq!(config["host"], "localhost");

    // Пустая карта:
    let empty: std::collections::HashMap<String, String> = map!();
    assert!(empty.is_empty());

    // Разные типы:
    let scores = map! {
        1 => 100,
        2 => 200,
    };
    assert_eq!(scores[&1], 100);
}

Упражнение 9: собственная десериализация serde (гл. 11) ★★★ (~45 минут)

Спроектируйте обёртку Duration, которая десериализуется из читаемых человеком строк вроде "30s", "5m", "2h" с помощью собственного десериализатора serde. Структура также должна сериализоваться обратно в тот же формат.

🔑 Решение
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;

#[derive(Debug, Clone, PartialEq)]
struct HumanDuration(std::time::Duration);

impl HumanDuration {
    fn from_str(s: &str) -> Result<Self, String> {
        let s = s.trim();
        if s.is_empty() { return Err("пустая строка длительности".into()); }

        let (num_str, suffix) = s.split_at(
            s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len())
        );
        let value: u64 = num_str.parse()
            .map_err(|_| format!("некорректное число: {num_str}"))?;

        let duration = match suffix {
            "s" | "sec"  => std::time::Duration::from_secs(value),
            "m" | "min"  => std::time::Duration::from_secs(value * 60),
            "h" | "hr"   => std::time::Duration::from_secs(value * 3600),
            "ms"         => std::time::Duration::from_millis(value),
            other        => return Err(format!("неизвестный суффикс: {other}")),
        };
        Ok(HumanDuration(duration))
    }
}

impl fmt::Display for HumanDuration {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let secs = self.0.as_secs();
        if secs == 0 {
            write!(f, "{}ms", self.0.as_millis())
        } else if secs % 3600 == 0 {
            write!(f, "{}h", secs / 3600)
        } else if secs % 60 == 0 {
            write!(f, "{}m", secs / 60)
        } else {
            write!(f, "{}s", secs)
        }
    }
}

impl Serialize for HumanDuration {
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        serializer.serialize_str(&self.to_string())
    }
}

impl<'de> Deserialize<'de> for HumanDuration {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        let s = String::deserialize(deserializer)?;
        HumanDuration::from_str(&s).map_err(serde::de::Error::custom)
    }
}

#[derive(Debug, Deserialize, Serialize)]
struct Config {
    timeout: HumanDuration,
    retry_interval: HumanDuration,
}

fn main() {
    let json = r#"{ "timeout": "30s", "retry_interval": "5m" }"#;
    let config: Config = serde_json::from_str(json).unwrap();

    assert_eq!(config.timeout.0, std::time::Duration::from_secs(30));
    assert_eq!(config.retry_interval.0, std::time::Duration::from_secs(300));

    // Корректно выполняется туда и обратно:
    let serialized = serde_json::to_string(&config).unwrap();
    assert!(serialized.contains("30s"));
    assert!(serialized.contains("5m"));
    println!("Конфигурация: {serialized}");
}

Упражнение 10: конкурентный загрузчик с таймаутом ★★ (~25 минут)

Напишите асинхронную функцию fetch_all, которая запускает три задачи tokio::spawn, каждая из которых имитирует сетевой вызов через tokio::time::sleep. Объедините все три через tokio::try_join!, обёрнутый в tokio::time::timeout(Duration::from_secs(5), ...). Верните Result<Vec<String>, ...> или ошибку, если какая-либо задача завершилась неудачно или истёк срок.

Цели обучения: tokio::spawn, try_join!, timeout, распространение ошибок через границы задач.

Подсказка

Каждая запущенная задача возвращает Result<String, _>. try_join! разворачивает все три. Оберните весь try_join! в timeout(): ошибка Elapsed означает, что вы достигли дедлайна.

Решение
use tokio::time::{sleep, timeout, Duration};

async fn fake_fetch(name: &'static str, delay_ms: u64) -> Result<String, String> {
    sleep(Duration::from_millis(delay_ms)).await;
    Ok(format!("{name}: OK"))
}

async fn fetch_all() -> Result<Vec<String>, Box<dyn std::error::Error>> {
    let deadline = Duration::from_secs(5);

    let (a, b, c) = timeout(deadline, async {
        let h1 = tokio::spawn(fake_fetch("svc-a", 100));
        let h2 = tokio::spawn(fake_fetch("svc-b", 200));
        let h3 = tokio::spawn(fake_fetch("svc-c", 150));
        tokio::try_join!(h1, h2, h3)
    })
    .await??; // первый ? = таймаут, второй ? = join

    Ok(vec![a?, b?, c?]) // разворачиваем внутренние Result
}

#[tokio::main]
async fn main() {
    let results = fetch_all().await.unwrap();
    for r in &results {
        println!("{r}");
    }
}

Упражнение 11: конвейер async-каналов ★★★ (~40 минут)

Постройте конвейер «производитель → преобразователь → потребитель» с помощью tokio::sync::mpsc:

  1. Производитель: отправляет целые числа 1..=20 в канал A (ёмкость 4).
  2. Преобразователь: читает из канала A, возводит каждое значение в квадрат и отправляет в канал B.
  3. Потребитель: читает из канала B, собирает значения в Vec<u64> и возвращает его.

Все три этапа работают как конкурентные задачи tokio::spawn. Используйте ограниченные каналы, чтобы продемонстрировать обратное давление. Проверьте, что итоговый вектор равен [1, 4, 9, ..., 400].

Цели обучения: mpsc::channel, ограниченное обратное давление, tokio::spawn с замыканиями move, корректное завершение через закрытие канала.

Решение
use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
    let (tx_a, mut rx_a) = mpsc::channel::<u64>(4); // ограниченный: обратное давление
    let (tx_b, mut rx_b) = mpsc::channel::<u64>(4);

    // Производитель
    let producer = tokio::spawn(async move {
        for i in 1..=20u64 {
            tx_a.send(i).await.unwrap();
        }
        // tx_a уничтожается здесь → канал A закрывается
    });

    // Преобразователь
    let transformer = tokio::spawn(async move {
        while let Some(val) = rx_a.recv().await {
            tx_b.send(val * val).await.unwrap();
        }
        // tx_b уничтожается здесь → канал B закрывается
    });

    // Потребитель
    let consumer = tokio::spawn(async move {
        let mut results = Vec::new();
        while let Some(val) = rx_b.recv().await {
            results.push(val);
        }
        results
    });

    producer.await.unwrap();
    transformer.await.unwrap();
    let results = consumer.await.unwrap();

    let expected: Vec<u64> = (1..=20).map(|x: u64| x * x).collect();
    assert_eq!(results, expected);
    println!("Конвейер завершён: {results:?}");
}

Итоги и справочная карточка

Справочная карточка

Руководство по выбору паттерна

Нужна безопасность типов для примитивов?
└── Паттерн newtype (гл. 3)

Нужно обеспечить состояния на этапе компиляции?
└── Паттерн type-state (гл. 3)

Нужен «тег» без данных во время выполнения?
└── PhantomData (гл. 4)

Нужно разорвать циклы ссылок Rc/Arc?
└── Weak<T> / sync::Weak<T> (гл. 9)

Нужно дождаться условия без активного опроса?
└── Condvar + Mutex (гл. 6)

Нужно обрабатывать «один из N типов»?
├── Известное закрытое множество → перечисление (enum)
├── Открытое множество, горячий путь → обобщения
├── Открытое множество, холодный путь → dyn Trait
└── Полностью неизвестные типы → Any + TypeId (гл. 2)

Нужно разделяемое состояние между потоками?
├── Простой счётчик или флаг → атомарные типы
├── Короткая критическая секция → Mutex
├── Много чтений → RwLock
├── Ленивая однократная инициализация → OnceLock / LazyLock (гл. 6)
└── Сложное состояние → актор + каналы

Нужно распараллелить вычисления?
├── Обработка коллекций → rayon::par_iter
├── Фоновая задача → thread::spawn
└── Заимствование локальных данных → thread::scope

Нужен асинхронный ввод-вывод или конкурентная работа с сетью?
├── Базовый уровень → tokio + async/await (гл. 16)
└── Продвинутый (потоки, middleware) → см. Async Rust Training

Нужна обработка ошибок?
├── Библиотека → thiserror (#[derive(Error)])
└── Приложение → anyhow (Result<T>)

Нужно запретить перемещение значения?
└── Pin<T> (гл. 9): обязателен для Future и самоссылающихся типов

Шпаргалка по границам трейтов

ОграничениеЗначение
T: CloneМожно дублировать
T: SendМожно передать в другой поток
T: Sync&T можно разделять между потоками
T: 'staticНе содержит заимствованных ссылок с ограниченным временем жизни
T: SizedРазмер известен на этапе компиляции (по умолчанию)
T: ?SizedРазмер может быть неизвестен ([T], dyn Trait)
T: UnpinБезопасно перемещать после закрепления (pin)
T: DefaultЕсть значение по умолчанию
T: Into<U>Можно преобразовать в U
T: AsRef<U>Можно заимствовать как &U
T: Deref<Target = U>Автоматически разыменовывается в &U
F: Fn(A) -> BВызываемый, заимствует состояние неизменяемо
F: FnMut(A) -> BВызываемый, может изменять состояние
F: FnOnce(A) -> BВызываемый ровно один раз, может потребить состояние

Правила elision времён жизни

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

#![allow(unused)]
fn main() {
// Правило 1: каждый параметр-ссылка получает собственное время жизни
// fn foo(x: &str, y: &str)  →  fn foo<'a, 'b>(x: &'a str, y: &'b str)

// Правило 2: если есть ровно ОДНО входное время жизни, оно используется для всех выходных
// fn foo(x: &str) -> &str   →  fn foo<'a>(x: &'a str) -> &'a str

// Правило 3: если один из параметров это &self или &mut self, используется его время жизни
// fn foo(&self, x: &str) -> &str  →  fn foo<'a>(&'a self, x: &str) -> &'a str
}

Когда время жизни НУЖНО писать явно:

  • Несколько входных ссылок и ссылка на выходе (компилятор не может угадать, какой вход имеется в виду)
  • Поля структур, которые хранят ссылки: struct Ref<'a> { data: &'a str }
  • Границы 'static, когда нужны данные без заимствованных ссылок

Распространённые производные трейты

#![allow(unused)]
fn main() {
#[derive(
    Debug,          // Форматирование {:?}
    Clone,          // .clone()
    Copy,           // Неявное копирование (только для простых типов)
    PartialEq, Eq,  // Сравнение ==
    PartialOrd, Ord, // Сравнение < > и сортировка
    Hash,           // Ключ HashMap/HashSet
    Default,        // Type::default()
)]
struct MyType { /* ... */ }
}

Краткая справка по видимости модулей

pub           → видим везде
pub(crate)    → видим внутри крейта
pub(super)    → видим родительскому модулю
pub(in path)  → видим внутри конкретного пути
(ничего)      → приватен для текущего модуля и его потомков

Дополнительная литература

РесурсПочему стоит прочитать
Rust Design PatternsКаталог идиоматичных паттернов и антипаттернов
Rust API GuidelinesОфициальный чек-лист для аккуратных публичных API
Rust Atomics and LocksГлубокий разбор примитивов конкурентности от Mara Bos
The RustonomiconОфициальное руководство по unsafe Rust и тёмным углам языка
Error Handling in RustПодробное руководство Andrew Gallant
Jon Gjengset — Crust of Rust seriesГлубокие разборы итераторов, времён жизни, каналов и др.
Effective Rust35 конкретных способов улучшить ваш код на Rust

Конец книги «Паттерны Rust и практические руководства по инженерии»

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

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

Примерное время: 4–6 часов | Сложность: ★★★

Что вы будете практиковать:

  • Обобщённые типы и границы трейтов (гл. 1–2)
  • Паттерн typestate для жизненного цикла задачи (гл. 3)
  • PhantomData для маркеров состояний без затрат во время выполнения (гл. 4)
  • Каналы для взаимодействия с воркерами (гл. 5)
  • Конкурентность со scoped-потоками (гл. 6)
  • Обработка ошибок с thiserror (гл. 10)
  • Тестирование свойств (гл. 14)
  • Дизайн API с TryFrom и проверенными типами (гл. 15)

Постановка задачи

Постройте планировщик задач, в котором:

  1. Задачи имеют типизированный жизненный цикл: Pending → Running → Completed (или Failed)
  2. Воркеры берут задачи из канала, выполняют их и сообщают результаты
  3. Планировщик управляет отправкой задач, координацией воркеров и сбором результатов
  4. Недопустимые переходы состояний являются ошибками компиляции
stateDiagram-v2
    [*] --> Pending: scheduler.submit(task)
    Pending --> Running: воркер берёт задачу
    Running --> Completed: задача успешна
    Running --> Failed: задача вернула Err
    Completed --> [*]: scheduler.results()
    Failed --> [*]: scheduler.results()

    Pending --> Pending: ❌ нельзя выполнить напрямую
    Completed --> Running: ❌ нельзя запустить повторно

Шаг 1: определите типы задач

Начните с маркеров typestate и обобщённого Task:

#![allow(unused)]
fn main() {
use std::marker::PhantomData;

// --- Маркеры состояний (нулевого размера) ---
struct Pending;
struct Running;
struct Completed;
struct Failed;

// --- Идентификатор задачи (newtype для безопасности типов) ---
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct TaskId(u64);

// --- Структура Task, параметризованная состоянием жизненного цикла ---
struct Task<State, R> {
    id: TaskId,
    name: String,
    _state: PhantomData<State>,
    _result: PhantomData<R>,
}
}

Ваша задача: реализуйте переходы состояний так, чтобы:

  • Task<Pending, R> переходит в Task<Running, R> (через start())
  • Task<Running, R> переходит в Task<Completed, R> или Task<Failed, R>
  • Никакие другие переходы не должны компилироваться
💡 Подсказка

Каждый метод перехода должен поглощать self и возвращать новое состояние:

#![allow(unused)]
fn main() {
impl<R> Task<Pending, R> {
    fn start(self) -> Task<Running, R> {
        Task {
            id: self.id,
            name: self.name,
            _state: PhantomData,
            _result: PhantomData,
        }
    }
}
}

Шаг 2: определите рабочую функцию

Задачам нужна функция для выполнения. Используйте замыкание в Box:

#![allow(unused)]
fn main() {
struct WorkItem<R: Send + 'static> {
    id: TaskId,
    name: String,
    work: Box<dyn FnOnce() -> Result<R, String> + Send>,
}
}

Ваша задача: реализуйте WorkItem::new(), который принимает имя задачи и замыкание. Добавьте генератор TaskId (простой атомарный счётчик или счётчик под мьютексом).

Шаг 3: обработка ошибок

Определите типы ошибок планировщика с помощью thiserror:

use thiserror::Error;

#[derive(Error, Debug)]
pub enum SchedulerError {
    #[error("планировщик остановлен")]
    ShutDown,

    #[error("задача {0:?} завершилась с ошибкой: {1}")]
    TaskFailed(TaskId, String),

    #[error("ошибка отправки в канал")]
    ChannelError(#[from] std::sync::mpsc::SendError<()>),

    #[error("воркер завершился паникой")]
    WorkerPanic,
}

Шаг 4: планировщик

Постройте планировщик с помощью каналов (гл. 5) и scoped-потоков (гл. 6):

#![allow(unused)]
fn main() {
use std::sync::mpsc;

struct Scheduler<R: Send + 'static> {
    sender: Option<mpsc::Sender<WorkItem<R>>>,
    results: mpsc::Receiver<TaskResult<R>>,
    num_workers: usize,
}

struct TaskResult<R> {
    id: TaskId,
    name: String,
    outcome: Result<R, String>,
}
}

Ваша задача: реализуйте:

  • Scheduler::new(num_workers: usize) -> Self: создаёт каналы и запускает воркеров
  • Scheduler::submit(&self, item: WorkItem<R>) -> Result<TaskId, SchedulerError>
  • Scheduler::shutdown(self) -> Vec<TaskResult<R>>: отбрасывает отправителя, дожидается воркеров и собирает результаты
💡 Подсказка: цикл воркера
#![allow(unused)]
fn main() {
fn worker_loop<R: Send + 'static>(
    rx: std::sync::Arc<std::sync::Mutex<mpsc::Receiver<WorkItem<R>>>>,
    result_tx: mpsc::Sender<TaskResult<R>>,
    worker_id: usize,
) {
    loop {
        let item = {
            let rx = rx.lock().unwrap();
            rx.recv()
        };
        match item {
            Ok(work_item) => {
                let outcome = (work_item.work)();
                let _ = result_tx.send(TaskResult {
                    id: work_item.id,
                    name: work_item.name,
                    outcome,
                });
            }
            Err(_) => break, // Канал закрыт
        }
    }
}
}

Шаг 5: интеграционный тест

Напишите тесты, которые проверяют:

  1. Успешный сценарий: отправьте 10 задач, остановите планировщик и убедитесь, что все 10 результатов имеют значение Ok
  2. Обработка ошибок: отправьте задачи, которые завершаются ошибкой, и убедитесь, что TaskResult.outcome равен Err
  3. Пустой планировщик: создайте и сразу остановите его, паник быть не должно
  4. Тест свойств (бонус): используйте proptest, чтобы проверить, что для любого N задач (1..100) планировщик всегда возвращает ровно N результатов
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn happy_path() {
        let scheduler = Scheduler::<String>::new(4);

        for i in 0..10 {
            let item = WorkItem::new(
                format!("задача-{i}"),
                move || Ok(format!("результат-{i}")),
            );
            scheduler.submit(item).unwrap();
        }

        let results = scheduler.shutdown();
        assert_eq!(results.len(), 10);
        for r in &results {
            assert!(r.outcome.is_ok());
        }
    }

    #[test]
    fn handles_failures() {
        let scheduler = Scheduler::<String>::new(2);

        scheduler.submit(WorkItem::new("удачная", || Ok("ок".into()))).unwrap();
        scheduler.submit(WorkItem::new("неудачная", || Err("сбой".into()))).unwrap();

        let results = scheduler.shutdown();
        assert_eq!(results.len(), 2);

        let failures: Vec<_> = results.iter()
            .filter(|r| r.outcome.is_err())
            .collect();
        assert_eq!(failures.len(), 1);
    }
}
}

Шаг 6: соберём всё вместе

Вот main(), который демонстрирует работу всей системы:

fn main() {
    let scheduler = Scheduler::<String>::new(4);

    // Отправляем задачи с разной нагрузкой
    for i in 0..20 {
        let item = WorkItem::new(
            format!("вычисление-{i}"),
            move || {
                // Имитация работы
                std::thread::sleep(std::time::Duration::from_millis(10));
                if i % 7 == 0 {
                    Err(format!("задача {i}: смоделированная ошибка"))
                } else {
                    Ok(format!("задача {i} завершена, значение {}", i * i))
                }
            },
        );
        // ПРИМЕЧАНИЕ: .unwrap() используется для краткости — в продакшене обрабатывайте SendError.
        scheduler.submit(item).unwrap();
    }

    println!("Все задачи отправлены. Останавливаем планировщик...");
    let results = scheduler.shutdown();

    let (ok, err): (Vec<_>, Vec<_>) = results.iter()
        .partition(|r| r.outcome.is_ok());

    println!("\n✅ Успешно: {}", ok.len());
    for r in &ok {
        println!("  {} → {}", r.name, r.outcome.as_ref().unwrap());
    }

    println!("\n❌ С ошибкой: {}", err.len());
    for r in &err {
        println!("  {} → {}", r.name, r.outcome.as_ref().unwrap_err());
    }
}

Критерии оценки

КритерийТребование
Безопасность типовНедопустимые переходы состояний не компилируются
КонкурентностьВоркеры работают параллельно, гонок данных нет
Обработка ошибокВсе сбои сохраняются в TaskResult, паник нет
ТестированиеНе менее 3 тестов; бонус за proptest
Организация кодаПонятная структура модулей, публичный API использует проверенные типы
ДокументацияУ ключевых типов есть doc-комментарии с описанием инвариантов

Идеи для развития

Когда базовый планировщик заработает, попробуйте эти улучшения:

  1. Очередь с приоритетами: добавьте newtype Priority (1–10) и сначала обрабатывайте задачи с более высоким приоритетом
  2. Политика повторов: задача, завершившаяся ошибкой, повторяется до N раз, прежде чем получит статус окончательно проваленной
  3. Отмена: добавьте метод cancel(TaskId), который удаляет ожидающие задачи
  4. Асинхронная версия: перенесите на tokio::spawn с каналами tokio::sync::mpsc (гл. 16)
  5. Метрики: отслеживайте количество задач по каждому воркеру, среднее время выполнения и долю ошибок