Конспекты Rust

Язык программирования Rust
по официальной книге The Rust Programming Language

34 уроков · 6 практик · 2 части

Материалы сокращены и переведены для удобной учёбы. Полный текст: doc.rust-lang.org/book

Часть I. Основы Rust

Глава 1

Начало работы и базовые концепции

Урок 1 · Часть I. Основы Rust

Установка и Hello, World

Источник: ch01-01-installation.html

Кратко

Чтобы писать на Rust, сначала ставят инструменты через rustup — это официальный установщик и менеджер версий. Представьте его как «пульт управления»: он ставит компилятор, обновляет его и при необходимости удаляет. Первая программа — это функция fn main(), внутри которой макрос println! печатает текст на экран. Код компилируют командой rustc, и получается обычный исполняемый файл. Его можно запускать даже на компьютере, где Rust не установлен.

Основное

  • Установка (Linux/macOS): curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh. На Windows — rustup-init.exe.

Эта команда скачивает скрипт установки и запускает его. После установки обычно нужно перезапустить терминал или выполнить подсказку про PATH, иначе система не найдёт rustc и cargo. На Windows проще скачать rustup-init.exe с сайта rust-lang.org и запустить его. Если установка прошла успешно, в терминале появятся rustc, cargo и rustup.

  • Нужен линкер (C-компилятор): gcc/clang на Linux, Xcode CLT на macOS, MSVC Build Tools на Windows.

Компилятор Rust превращает ваш код в объектные файлы, а линкер склеивает их в готовый бинарник. Без линкера rustc выдаст ошибку вроде «linker not found». На Linux обычно ставят пакет с gcc или clang, на macOS — Command Line Tools через Xcode, на Windows — Build Tools for Visual Studio. Это не «вторая установка Rust», а обязательная часть цепочки сборки.

  • Проверка: rustc --version. Обновление: rustup update. Удаление: rustup self uninstall. Локальная документация: rustup doc.

rustc --version сразу показывает, что компилятор доступен. rustup update подтягивает новые стабильные версии инструментов — это нормальная регулярная практика. rustup self uninstall полностью убирает toolchain. rustup doc открывает локальную копию документации (включая The Book) в браузере — удобно без интернета.

  • Файлы Rust имеют расширение .rs, имена пишут в snake_case.

Например, main.rs или guessing_game.rs. Соглашение snake_case (слова через подчёркивание, маленькие буквы) касается и имён файлов, и функций, и переменных. Компилятор не требует именно такого стиля для файлов, но сообщество и инструменты (rustfmt, линтеры) ожидают его. Единый стиль облегчает чтение чужого кода.

  • fn main() — точка входа. Тело функции в {}, отступ — 4 пробела.

Когда вы запускаете программу, выполнение всегда начинается с main. Без этой функции бинарный крейт не соберётся. Фигурные скобки ограничивают тело функции. Четыре пробела — стандарт сообщества; rustfmt выставит их автоматически, если вы забудете.

  • println! — макрос (признак — !), а не функция. Строки заканчиваются ;.

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

  • Rust — компилируемый язык с ahead-of-time компиляцией: результат (main) можно запускать без установленного Rust.

«Ahead-of-time» значит: весь код переводят в машинный код до запуска, а не во время выполнения (как у многих интерпретируемых языков). Готовый файл main или main.exe — самостоятельная программа. Это удобно для раздачи: пользователю не нужен установленный Rust.

  • Компиляция: rustc main.rs, запуск: ./main (на Windows .\main.exe).

rustc читает исходник и создаёт исполняемый файл рядом (имя обычно совпадает с именем файла без .rs). Затем его запускают как любую программу в терминале. Для одного файла этого достаточно; для проектов с зависимостями почти всегда используют Cargo, а не «голый» rustc.

  • rustfmt / cargo fmt — стандартное форматирование.

Эти команды приводят код к единому стилю: отступы, переносы, пробелы. Не нужно спорить о форматировании вручную — инструмент делает это за вас. В проектах Cargo обычно пишут cargo fmt.

Примеры

// main.rs — минимальная программа
fn main() {
    println!("Hello, world!");
}
# компиляция и запуск
rustc main.rs
./main
fn main() {
    let name = "Rust";
    // {name} подставляет значение переменной в строку
    println!("Привет, {name}!");
}

Важно запомнить

  • rustup сразу ставит rustc, cargo и rustfmt — этого достаточно, чтобы начать.
  • println! — макрос: в имени есть !, это не обычная функция.
  • rustc удобен для одного файла; для реальных проектов используют Cargo.
  • Без линкера (gcc/clang/MSVC) компиляция не завершится успешно.

Урок 2 · Часть I. Основы Rust

Cargo

Источник: ch01-03-hello-cargo.html

Кратко

Cargo — это система сборки и менеджер пакетов для Rust. Он похож на «умный помощник проекта»: создаёт структуру папок, собирает код, запускает программу и скачивает чужие библиотеки (крейты). Вместо того чтобы вручную вызывать rustc и следить за зависимостями, вы почти всегда работаете через команды cargo. Debug-сборка удобна для разработки, а --release — когда нужна скорость готовой программы. Почти весь экосистемный код Rust распространяется через Cargo и crates.io.

Основное

  • cargo new hello_cargo создаёт каталог с Cargo.toml, src/main.rs и репозиторием Git.

Команда делает готовый каркас проекта: не нужно вручную создавать файлы и писать Hello World с нуля. По умолчанию инициализируется Git-репозиторий (если Git установлен). Имя проекта становится именем пакета. Если создать проект неправильно «руками» без Cargo.toml, обычные команды Cargo просто не сработают.

  • Cargo.toml — манифест в формате TOML: секции [package] (имя, версия, edition) и [dependencies].

Это «паспорт» проекта. В [package] указывают метаданные, в [dependencies] — внешние библиотеки и их версии. TOML читается проще, чем JSON: секции, ключи и значения. Ошибка в синтаксисе Cargo.toml обычно сразу ломает cargo build, поэтому файл правят осторожно. Поле edition задаёт редакцию языка (например, "2021").

  • Исходники лежат в src/; корень проекта — для README, лицензии, конфигов.

Cargo ожидает src/main.rs для бинарного проекта или src/lib.rs для библиотеки. Документацию, лицензию и настройки держат в корне рядом с Cargo.toml. Такая раскладка — соглашение экосистемы: любой разработчик сразу понимает, где код, а где «обвязка» проекта.

  • Команды:

- cargo build — сборка в target/debug/;

- cargo run — сборка и запуск;

- cargo check — проверка без создания бинарника (быстрее);

- cargo build --release — оптимизированная сборка в target/release/.

cargo build компилирует и кладёт результат в target/debug/. cargo run делает то же и сразу запускает программу — самый частый цикл «написал → проверил». cargo check только проверяет типы и заимствования, не создавая исполняемый файл: это быстрее и удобно при частых правках. --release включает оптимизации: программа работает быстрее, но собирается дольше.

  • Cargo.lock фиксирует точные версии зависимостей; создаётся автоматически.

В Cargo.toml вы пишете диапазон вроде rand = "0.8", а в Cargo.lock — точную версию, которую реально скачали. Благодаря этому у вас и у коллеги сборка одинаковая. Для бинарных приложений Cargo.lock обычно коммитят в Git. Не редактируйте lock-файл вручную — его обновляет Cargo.

  • Debug-сборка быстро компилируется, но медленно работает; release — наоборот.

В debug компилятор почти не оптимизирует код, зато быстрее пересобирает и даёт больше проверок (например, панику при переполнении целых). В release оптимизации включаются: программа «лёгкая и быстрая», но сборка дольше. Для ежедневной разработки — debug; для замера скорости и поставки пользователям — release.

  • Для существующего проекта: git clone ..., cd ..., cargo build.

После клонирования репозитория достаточно зайти в каталог и собрать: Cargo сам скачает зависимости по Cargo.toml и Cargo.lock. Отдельный «install dependencies» как в некоторых экосистемах не нужен — всё происходит при первой сборке. Если сеть недоступна и крейтов нет в кэше, сборка упадёт.

  • cargo add имя добавляет зависимость; cargo update обновляет версии в рамках Cargo.toml.

cargo add rand допишет зависимость в Cargo.toml и обновит lock. cargo update подтянет более новые версии в пределах указанных вами ограничений, но не прыгнет на мажорную версию, которую вы не разрешили. Это безопасный способ обновляться без неожиданной ломки API.

Примеры

# Cargo.toml — манифест проекта
[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2021"

[dependencies]
rand = "0.8"
cargo new hello_cargo
cd hello_cargo
cargo run          # собрать и запустить
cargo check        # только проверить
cargo build --release
// src/main.rs, созданный cargo new
fn main() {
    println!("Hello, world!");
}

Важно запомнить

  • В повседневной работе чаще всего хватает cargo run и быстрого cargo check.
  • Флаг --release нужен для измерения производительности и финальной поставки.
  • Каталог target/ не коммитят в Git: он большой и уже есть в .gitignore.
  • Cargo.lock фиксирует точные версии и делает сборку воспроизводимой.

Урок 3 · Часть I. Основы Rust

Игра «Угадай число»

Источник: ch02-00-guessing-game-tutorial.html

Кратко

Это учебный мини-проект из The Rust Book: программа загадывает число от 1 до 100, а вы пытаетесь его угадать. По пути вы впервые встречаете переменные, циклы, match, чтение с клавиатуры и внешнюю библиотеку rand. Игра маленькая, но через неё проходит много «настоящих» идей Rust: ввод — это строка, ошибки нельзя молча игнорировать, типы нужно согласовывать. Если что-то пойдёт не так (пустой ввод, буквы вместо цифр), программа должна либо корректно продолжить, либо явно упасть с понятным сообщением.

Основное

  • Ввод: use std::io; подключает модуль. io::stdin().read_line(&mut guess) дописывает строку в guess.

Модуль std::io даёт доступ к стандартному вводу. read_line читает строку до Enter и дописывает её в буфер (включая символ перевода строки). Поэтому переменная должна быть изменяемой строкой, а передают её как &mut guess. Если передать неизменяемую переменную, компилятор не даст скомпилировать код.

  • Переменные: let создаёт неизменяемую переменную, let mut — изменяемую.

По умолчанию Rust защищает данные от случайной перезаписи. Для буфера ввода изменение нужно, поэтому пишут let mut guess = String::new();. Если забыть mut, ошибка компиляции сразу укажет на место. Это не «лишняя строгость», а способ явно помечать места, где состояние меняется.

  • &mut guess — изменяемая ссылка; read_line возвращает Result (Ok / Err).

Ссылка позволяет функции писать в вашу строку, не забирая владение. Result — это способ сказать: «операция могла удаться или провалиться». Игнорирование Result вызывает предупреждение компилятора: ввод с клавиатуры реально может завершиться ошибкой, и Rust хочет, чтобы вы это учли.

  • .expect("сообщение") — паника при Err, иначе возвращает значение из Ok.

Для первых программ expect — простой способ: если чтение не удалось, программа падает с вашим текстом. Это грубее, чем полноценная обработка, но честнее, чем молчаливый провал. Позже тот же Result разбирают через match, чтобы не завершать всю программу из‑за одной опечатки пользователя.

  • {} и {name} в println! — плейсхолдеры.

Вместо склейки строк вручную вы вставляете значения прямо в шаблон. {name} подставляет переменную name. Это читается проще и меньше подвержено ошибкам форматирования. Для отладки сложных значений часто используют {:?}.

  • Случайные числа: внешний крейт rand. cargo add rand → rand::thread_rng().gen_range(1..=100) (в версиях 0.8; use rand::Rng; обязателен).

В стандартной библиотеке нет генератора случайных чисел «из коробки» для такой игры, поэтому подключают крейт. 1..=100 — диапазон включительно до 100. Трейт Rng нужно импортировать (use rand::Rng;), иначе метод gen_range не будет виден. Версии API у rand менялись — ориентируйтесь на документацию той версии, что в Cargo.toml.

  • Сравнение: guess.cmp(&secret) возвращает Ordering (Less, Greater, Equal); разбирают через match.

Вместо цепочки if guess < secret используют единый результат сравнения. match обязан покрыть все варианты Ordering, иначе код не скомпилируется. Так сложнее забыть ветку «равно» или «больше».

  • Приведение типов: ввод — String, секрет — число. let guess: u32 = guess.trim().parse()... — затенение (shadowing) допускает то же имя.

Пользователь всегда вводит текст. trim() убирает пробелы и \n, parse() пытается сделать число. Затенение (let guess: u32 = ... после let mut guess = String::...) позволяет назвать новую переменную тем же именем с другим типом. Это удобно и типично для Rust; путать со «сменой типа на месте» не стоит — создаётся новая переменная.

  • Цикл: loop { ... }; break выходит, continue идёт на следующую итерацию.

loop крутится бесконечно, пока вы сами не выйдете. При неверном вводе делают continue (спросить снова), при угадывании — break. Без цикла пришлось бы копировать код «спросить и сравнить» много раз.

  • Ошибки ввода: вместо expect используют match по Result: Ok(num) => num, Err(_) => continue.

Если пользователь ввёл «abc», parse вернёт Err. Вместо паники игра просто просит ввод ещё раз. _ означает «любая ошибка, детали не важны». Так программа становится дружелюбнее и ближе к реальному CLI.

Примеры

use std::cmp::Ordering;
use std::io;

use rand::Rng; // нужен для gen_range

fn main() {
    println!("Угадай число от 1 до 100!");
    let secret: u32 = rand::thread_rng().gen_range(1..=100);

    loop {
        println!("Введите число:");
        let mut guess = String::new();
        io::stdin()
            .read_line(&mut guess)
            .expect("Не удалось прочитать строку");

        // затенение: String -> u32; при ошибке — новая попытка
        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        match guess.cmp(&secret) {
            Ordering::Less => println!("Слишком мало"),
            Ordering::Greater => println!("Слишком много"),
            Ordering::Equal => {
                println!("Вы угадали!");
                break;
            }
        }
    }
}
[dependencies]
rand = "0.8"

Важно запомнить

  • Ввод с клавиатуры всегда приходит как строка — нужны trim() и parse() для числа.
  • Значение типа Result нельзя просто игнорировать: компилятор предупредит.
  • В match нужно обработать все варианты, иначе код не соберётся.
  • Затенение (let guess = ... повторно) удобно, когда меняется тип той же «логической» переменной.

Урок 4 · Часть I. Основы Rust

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

Источник: ch03-01-variables-and-mutability.html

Кратко

В Rust переменные по умолчанию неизменяемы: один раз записали значение — и больше его не перезаписываете. Это как закрытый контейнер с этикеткой: содержимое нельзя подменить без явного разрешения. Чтобы менять значение, пишут let mut. Отдельно существуют константы const — они ещё строже и известны уже при компиляции. А затенение (shadowing) — это когда вы создаёте новую переменную с тем же именем, иногда даже с другим типом. Эти три идеи помогают писать код, где случайные изменения состояния встречаются реже.

Основное

  • let x = 5; — неизменяемая; повторное присваивание — ошибка компиляции.

Если позже написать x = 6;, компилятор остановит сборку. Ошибка появится на этапе компиляции, а не «где‑то в проде». Неизменяемость по умолчанию заставляет явно думать: «это значение правда должно меняться?» Часто ответ — нет, и код становится проще рассуждать.

  • let mut x = 5; — изменяемая; тип менять нельзя.

Слово mut разрешает присваивать новое значение того же типа: x = 6; или x += 1;. Но превратить число в строку через повторное присваивание нельзя — тип переменной фиксирован. Если нужен другой тип, используют затенение новым let, а не mut.

  • Константы: const MAX_POINTS: u32 = 100_000;

- всегда неизменяемы, нельзя mut;

- тип указывается обязательно;

- значение — константное выражение, вычисляемое при компиляции;

- имена в SCREAMING_SNAKE_CASE; могут быть глобальными.

Константа — не «просто переменная без mut». Её значение должно быть известно компилятору заранее (например, 60 * 60), а не результат чтения файла или случайного числа. Имена пишут КАПСОМ через подчёркивание, чтобы сразу отличать от обычных переменных. Константы можно объявлять в области модуля — они доступны многим функциям.

  • Затенение: новый let с тем же именем создаёт новую переменную. Можно менять тип:

let spaces = " "; let spaces = spaces.len();

Вторая spaces — уже не та же ячейка, а новая переменная, которая «закрывает» предыдущее имя. Это удобно в цепочках преобразований: сначала строка, потом её длина, без изобретения имён spaces_str и spaces_len. Пока вы в той же области видимости, старое значение становится недоступным по этому имени.

  • Отличие от mut: затенение создаёт новую переменную (можно сменить тип), mut меняет значение на месте и тип сохраняет.

Аналогия: mut — это перекрасить ту же коробку; shadowing — поставить на полку новую коробку с той же биркой. Если написать let mut spaces = " "; spaces = spaces.len();, компилятор откажет: типы не совпадают. Затенение как раз для таких преобразований и задумано.

  • Затенение действует до конца блока; во внутреннем блоке оно не влияет на внешнюю переменную после выхода.

Внутри { let x = x * 2; ... } внутренняя x живёт только в блоке. Снаружи снова видна прежняя внешняя x. Это позволяет временно «перекрыть» имя, не ломая окружающий код. После } внутренняя переменная исчезает вместе с областью видимости.

  • Подчёркивание в числах 1_000 улучшает читаемость.

Для компилятора 1000 и 1_000 — одно и то же. Для человека 1_000_000 читается легче, чем длинная простыня цифр. Это синтаксический сахар, на значение не влияет.

Примеры

fn main() {
    let x = 5;
    // x = 6; // ошибка: нельзя присвоить дважды неизменяемой переменной
    let mut y = 5;
    y += 1; // mut разрешает изменение
    println!("x = {x}, y = {y}");
}
const SECONDS_IN_HOUR: u32 = 60 * 60; // известно при компиляции

fn main() {
    let x = 5;
    let x = x + 1; // затенение: новая переменная
    {
        let x = x * 2;
        println!("внутри: {x}"); // 12
    }
    println!("снаружи: {x}"); // 6
    println!("{SECONDS_IN_HOUR}");
}
fn main() {
    let spaces = "   ";
    let spaces = spaces.len(); // тип сменился: &str -> usize
    println!("{spaces}");

    let mut text = "abc";
    // text = text.len(); // ошибка: тип менять нельзя через mut
    text = "xyz";
    println!("{text}");
}

Важно запомнить

  • По умолчанию переменные неизменяемы — так случайные изменения ловятся раньше.
  • const подходит для значений, которые известны уже на этапе компиляции.
  • Затенение — это не mut: создаётся новая переменная, тип можно сменить.
  • Пишите mut только там, где значение действительно должно меняться со временем.

Урок 5 · Часть I. Основы Rust

Типы данных

Источник: ch03-02-data-types.html

Кратко

Rust — статически типизированный язык: тип каждой переменной известен ещё при компиляции, до запуска программы. Компилятор либо сам выводит тип из контекста, либо вы указываете его явно, когда неоднозначно. Типы делятся на скалярные (одно значение: число, bool, символ) и составные (несколько значений вместе: кортеж, массив). Правильный выбор типа влияет на память, диапазон чисел и безопасность: например, выход за границу массива в Rust даёт панику, а не «тихий» доступ к чужой памяти. Новичку полезно запомнить умолчания: целые — i32, вещественные — f64.

Основное

  • Тип выводится автоматически; если неоднозначно (например, parse), указывают явно: let n: u32 = "42".parse().unwrap();

Часто достаточно let x = 5; — компилятор поймёт i32. Но у "42".parse() много возможных целевых типов, поэтому без аннотации будет ошибка. Явный тип — не бюрократия, а способ сказать компилятору, что именно вы хотите получить. unwrap() здесь для краткости примера; в реальном коде ошибки parse обычно обрабатывают.

  • Целые: i8, i16, i32, i64, i128, isize (со знаком) и u8 ... usize (без знака). По умолчанию — i32. isize/usize зависят от архитектуры (индексы).

Буква i — signed (могут быть отрицательные), u — unsigned (только ≥ 0). Число в имени — битность: i8 от −128 до 127. isize/usize совпадают с размером указателя (32 или 64 бита) и часто используются для индексов и размеров коллекций. Если взять слишком узкий тип, значение может не влезть; слишком широкий — зря тратит память, хотя для большинства программ i32 — разумный старт.

  • Литералы: 98_222, 0xff, 0o77, 0b1111_0000, b'A' (только u8).

Подчёркивания только для чтения человеком. Префиксы 0x, 0o, 0b задают шестнадцатеричную, восьмеричную и двоичную запись. b'A' — байтовый литерал типа u8, полезен при работе с сырыми байтами, а не с Unicode-символами.

  • Переполнение: в debug — паника; в release — «оборачивание» (two's complement). Явно: wrapping_, checked_, saturating_, overflowing_.

Если u8 равен 255 и вы прибавляете 1, в debug-сборке программа обычно паникует — это помогает поймать баг. В release по умолчанию происходит оборачивание (255 + 1 → 0), как в классической арифметике дополнительных кодов. Чтобы поведение не зависело от режима сборки, вызывают явные методы: checked_add вернёт None, saturating_add «прижмёт» к максимуму, wrapping_add всегда оборачивает.

  • Вещественные: f32, f64 (по умолчанию f64).

Это числа с плавающей точкой по IEEE-754. f64 точнее и используется по умолчанию; f32 экономит память и иногда быстрее на определённом железе. Помните: вещественная арифметика приблизительна — сравнения «на точное равенство» бывают коварны.

  • Арифметика: + - * / %; целочисленное деление отбрасывает дробную часть.

7 / 2 для целых даст 3, а не 3.5. Чтобы получить дробный результат, операнды должны быть вещественными: 7.0 / 2.0. Остаток % тоже работает с целыми (и с float, но новичку важнее целый случай). Деление на ноль для целых приводит к панике.

  • bool: true / false, 1 байт.

Используется в условиях if, while и логических выражениях. В отличие от некоторых языков, число нельзя молча считать «истиной»: нужен именно bool. Это убирает целый класс случайных ошибок в условиях.

  • char: 4 байта, Unicode scalar value, в одинарных кавычках: 'z', 'ж', '😻'.

Это не «один байт как в C». Один char — скалярное значение Unicode, поэтому эмодзи и кириллица тоже допустимы. Строки (String / &str) — последовательности UTF-8 байт; индексировать их «как массив char» напрямую нельзя. Путать char и байт (u8) — частая ошибка новичков.

  • Кортеж: фиксированная длина, типы могут различаться. Доступ — деструктуризация или t.0. Пустой кортеж () — unit.

Кортеж удобен, когда нужно вернуть из функции несколько разных значений без отдельной структуры. let (x, y, z) = tup; разбирает его по именам. () означает «нет осмысленного значения» и является типом по умолчанию у функций без ->. Длина кортежа после создания не меняется.

  • Массив: фиксированная длина, один тип, лежит в стеке: [i32; 5], [3; 5] → [3,3,3,3,3]. Индексация a[0]; выход за границу — паника во время выполнения.

Все элементы одного типа, размер известен при компиляции. Это быстро и предсказуемо по памяти, но нельзя «дописать ещё один элемент». Обращение по неверному индексу не даёт неопределённого поведения — программа паникует. Для данных, чья длина меняется, используют Vec<T>.

  • Для изменяемой длины используют Vec<T>.

Вектор хранит данные в куче и может расти. На этом уроке Book лишь намекает на него; подробно векторы разбирают позже. Правило выбора простое: известная фиксированная длина и один тип на стеке — массив; нужна динамика — вектор.

Примеры

fn main() {
    let a: u8 = 255;
    println!("{:?}", a.checked_add(1)); // None — переполнение поймано
    println!("{}", a.wrapping_add(1)); // 0 — оборачивание

    let x = 2.0; // f64 по умолчанию
    let y: f32 = 3.0;
    println!("{x} {y}");
    println!("{}", 7 / 2); // 3 — дробная часть отброшена
    println!("{}", 7.0 / 2.0); // 3.5
}
fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
    let (x, y, z) = tup; // деструктуризация
    println!("{x} {y} {z} {}", tup.0);

    let arr = [1, 2, 3, 4, 5];
    let zeros = [0; 3]; // [0, 0, 0]
    let months: [&str; 2] = ["Январь", "Февраль"];
    println!("{} {:?} {:?}", arr[0], zeros, months);
}

Важно запомнить

  • Целое по умолчанию — i32, вещественное — f64.
  • Переполнение в debug обычно даёт панику; для явного контроля используйте checked_, wrapping_ и похожие методы.
  • char — это Unicode-символ (4 байта), а не один байт.
  • Индекс за границами массива вызывает панику, а не неопределённое поведение.

Урок 6 · Часть I. Основы Rust

Функции и комментарии

Источник: ch03-03-how-functions-work.html

Кратко

Функции в Rust объявляют через fn: это именованные куски логики с параметрами и, при желании, возвращаемым значением. У каждого параметра тип пишут явно — компилятор не угадывает «на глаз». Важно отличие операторов (statements) от выражений (expressions): выражение даёт значение, а оператор что‑то делает и значения не возвращает. Поэтому последнее выражение в функции без ; — это и есть результат возврата. Комментарии // помогают людям, а /// попадает в документацию, которую собирает cargo doc.

Основное

  • Имена функций — snake_case. Порядок объявления не важен.

Пишите calculate_area, а не CalculateArea. Функцию можно вызвать из main даже если она объявлена ниже по файлу — Rust не требует «сначала объяви, потом используй» внутри одного модуля так, как некоторые языки. Единый стиль имён делает код предсказуемым для читателя и инструментов.

  • Параметры: тип каждого указывается явно: fn add(a: i32, b: i32).

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

  • Оператор выполняет действие и не возвращает значение (let x = 5;). Выражение вычисляется в значение (5 + 6, вызов функции, блок {}, if).

Аналогия: оператор — инструкция «сделай шаг»; выражение — формула, у которой есть ответ. Почти всё в Rust является выражением, и это позволяет писать компактный код вроде let n = if cond { 1 } else { 2 };. Понимание этой пары снимает половину путаницы с ; и возвратами.

  • Нельзя писать let x = (let y = 6);.

let — оператор, у него нет значения, которое можно присвоить другой переменной. Такая конструкция — ошибка компиляции. Если нужно вычислить что‑то «внутри», используют блок-выражение: let x = { let y = 6; y };.

  • Возвращаемое значение: тип после ->. Значение — последнее выражение (без ;) либо явный return.

Сигнатура fn five() -> i32 обещает вернуть i32. Обычно пишут просто 5 или a + b в конце тела без точки с запятой. return тоже работает и полезен для раннего выхода из середины функции, но идиоматичный стиль Book — опираться на последнее выражение.

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

; превращает выражение в оператор: значение отбрасывается, блок возвращает unit (). Если функция обещала i32, компилятор сообщит о несовпадении. Это одна из самых частых ошибок новичков — и одна из самых полезных: компилятор прямо намекает убрать ;.

  • Функция без -> возвращает ().

fn log_message() { println!("hi"); } возвращает unit. Это нормально для функций «ради побочного эффекта». Не нужно писать -> () явно, хотя синтаксически можно. Вызов такой функции можно игнорировать — возвращать нечего осмысленного.

  • Комментарии:

- // строчный;

- / блочный / (редко);

- /// — документация к следующему элементу (Markdown), //! — к охватывающему модулю/крейту; cargo doc --open строит документацию.

Обычный // читает только человек в исходнике. /// описывает функцию, структуру или метод и попадает в HTML-документацию. Внутри /// можно писать Markdown и даже примеры кода. //! комментирует текущий модуль или крейт целиком. Блочные / / в Rust используют редко — чаще хватает строчных.

Примеры

fn main() {
    println!("{}", add(2, 3));
    println!("{}", five());

    let y = {
        let x = 3;
        x + 1 // без ';' — значение всего блока
    };
    println!("y = {y}");
}

fn add(a: i32, b: i32) -> i32 {
    a + b // возврат через выражение
}

fn five() -> i32 {
    return 5; // явный return тоже допустим
}
/// Возводит число в квадрат.
///
/// # Примеры
/// ```
/// assert_eq!(square(3), 9);
/// ```
fn square(n: i32) -> i32 {
    n * n // вернётся значение выражения
}

fn main() {
    // Обычный комментарий — только для читателя кода
    println!("{}", square(4));
}

Важно запомнить

  • Типы параметров и возвращаемого значения в сигнатуре указывают обязательно.
  • Точка с запятой после выражения превращает его в оператор — возвращаемое значение теряется.
  • Блоки, if и match — выражения и могут давать значение.
  • /// попадает в документацию (cargo doc), а обычный // — нет.

Урок 7 · Часть I. Основы Rust

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

Источник: ch03-05-control-flow.html

Кратко

Управляющие конструкции решают, какой код выполнить и сколько раз. Ветвление делают через if / else if / else, повторение — через loop, while и for. В Rust if — это выражение: ветки могут возвращать значение, которое сразу присваивают переменной. Условие обязано быть именно bool: число или строка «сами собой» истиной не станут. Для обхода коллекций почти всегда выбирают for — так меньше шансов ошибиться с индексами и выйти за границы.

Основное

  • if: условие без скобок, блоки в {}. Нет неявного приведения к bool (if 3 {} — ошибка).

Пишут if number % 2 == 0 { ... }, а не if (number % 2 == 0). Скобки вокруг условия допустимы, но не приняты стилем. Главное: нельзя написать if 3 в надежде, что «ненулевое значит истина». Компилятор потребует сравнение или другой bool. Это убирает целый класс скрытых багов из языков с неявным приведением.

  • if в let: let n = if cond { 5 } else { 6 }; — типы веток должны совпадать.

Поскольку if — выражение, его результат можно сохранить в переменную. Обе ветки должны вернуть один и тот же тип: нельзя в одной ветке дать число, а в другой — строку. Если else нет, а результат присваивают, возникнет проблема с типом (успешная ветка даёт значение, а «тишина» — по сути ()). Поэтому для let ... = if почти всегда нужен else.

  • loop — бесконечный цикл; выход break, пропуск итерации continue. break value; возвращает значение из цикла.

loop крутится, пока вы сами не остановите. Это удобно, когда условие выхода проверяют в середине тела (как в игре «угадай число»). Особенность Rust: break 10; может вернуть значение наружу, и его ловят через let result = loop { ... };. continue переходит к следующей итерации, не выполняя хвост текущей.

  • Метки циклов: 'outer: loop { ... break 'outer; } — выход из внешнего цикла.

При вложенных циклах обычный break выходит только из внутреннего. Метка вроде 'outer: даёт имя внешнему циклу, и break 'outer прыгает сразу из него. Это читается яснее, чем флаги «надо выйти» и цепочки проверок. Имя метки начинается с '.

  • while условие { } — пока условие истинно.

Перед каждой итерацией условие проверяется. Когда оно становится false, цикл заканчивается. Подходит, когда заранее неизвестно число шагов, но есть чёткий булев признак продолжения. Условие снова должно быть bool.

  • for x in коллекция — самый безопасный и частый способ обхода. Диапазоны: 1..4 (1,2,3), 1..=4 (1–4), .rev() — в обратном порядке.

for сам берёт элементы по очереди и не даёт «промахнуться» индексом так же легко, как ручной счётчик. Диапазон 1..4 полуоткрытый: 4 не входит. 1..=4 включает конец. Метод .rev() разворачивает порядок — удобно для обратного отсчёта. Это идиоматичный Rust для большинства обходов.

  • Индексный обход через while опасен (паника при ошибке индекса, медленнее), поэтому предпочитают for.

Классический цикл «индекс от 0 до length» легко ошибается в условии (<= вместо <) и паникует на доступе. Плюс такой код часто многословнее. Book прямо советует for как более безопасный и ясный вариант. Индексы оставляют на случаи, когда индекс действительно нужен по смыслу.

  • .iter().enumerate() даёт пары (индекс, значение).

Когда нужны и элемент, и его номер, не обязательно писать ручной счётчик: for (i, el) in a.iter().enumerate(). Индекс идёт с нуля. Так вы совмещаете безопасность итератора с удобством индекса, не управляя границей вручную.

Примеры

fn main() {
    let number = 7;
    if number % 2 == 0 {
        println!("чётное");
    } else if number % 3 == 0 {
        println!("делится на 3");
    } else {
        println!("прочее");
    }

    // if как выражение: обе ветки — &str
    let size = if number > 5 { "большое" } else { "малое" };
    println!("{size}");
}
fn main() {
    let mut counter = 0;
    let result = loop {
        counter += 1;
        if counter == 10 {
            break counter * 2; // вернуть значение из loop
        }
    };
    println!("{result}"); // 20

    let mut count = 0;
    'outer: loop {
        loop {
            count += 1;
            if count == 3 {
                break 'outer; // выход сразу из внешнего цикла
            }
        }
    }
}
fn main() {
    let mut n = 3;
    while n != 0 {
        println!("{n}!");
        n -= 1;
    }

    let a = [10, 20, 30];
    for el in a {
        println!("{el}");
    }
    for (i, el) in a.iter().enumerate() {
        println!("{i}: {el}");
    }
    for i in (1..4).rev() {
        println!("{i}");
    }
}

Важно запомнить

  • Условие у if и while должно иметь тип bool — без неявных преобразований.
  • if / else — выражение: типы веток при присваивании должны совпадать.
  • break значение возвращает результат только из loop (не из while/for в том же смысле).
  • Для обхода коллекций предпочитайте for, а не ручные индексы.

Практика 1 · Часть I. Основы Rust

основы Rust

Задания для закрепления темы · решений нет

Темы: установка, Cargo, переменные, типы, функции, управляющие конструкции (уроки 01-01 … 01-07).

Создавайте проект командой cargo new имя и запускайте через cargo run.

Задача 1. Приветствие

Сложность: лёгкое

Напишите программу, которая хранит имя в переменной и печатает Привет, <имя>! Добро пожаловать в Rust. с помощью подстановки {name}.

Ожидаемый вывод для name = "Анна":

Привет, Анна! Добро пожаловать в Rust.

Подсказка: let name = "Анна"; println!("... {name} ...");

Задача 2. Конвертер температуры

Сложность: лёгкое

Напишите функцию fn celsius_to_fahrenheit(c: f64) -> f64 (формула c * 9.0 / 5.0 + 32.0). В main выведите результат для значений -40.0, 0.0, 36.6.

Ожидаемый вывод:

-40 C = -40 F
0 C = 32 F
36.6 C = 97.88 F

Подсказка: используйте массив [f64; 3] и цикл for. Для округления до двух знаков: {:.2} (формат допускает небольшие отличия).

Задача 3. FizzBuzz

Сложность: среднее

Выведите числа от 1 до 30 включительно: для кратных 3 — Fizz, кратных 5 — Buzz, кратных обоим — FizzBuzz, иначе само число.

Подсказка: диапазон 1..=30, оператор %, цепочка if / else if / else.

Задача 4. Числа Фибоначчи

Сложность: среднее

Напишите функцию fn fib(n: u32) -> u64, возвращающую n-е число Фибоначчи (fib(0) = 0, fib(1) = 1). Реализуйте через цикл, без рекурсии. Выведите fib(0) … fib(15) и fib(50).

Ожидаемо: fib(10) = 55, fib(50) = 12586269025.

Подсказка: две mut-переменные a, b, на каждой итерации (a, b) = (b, a + b).

Задача 5. Угадай число с подсказками

Сложность: сложное

Допишите игру из урока 01-03:

  1. ограничьте число попыток 7; после этого выведите загаданное число и завершите программу;
  2. выводите номер текущей попытки;
  3. нечисловой ввод не должен тратить попытку;
  4. числа вне диапазона 1–100 сопровождайте сообщением, но попытку засчитывайте.

Подсказка: for attempt in 1..=7, метка цикла или continue/break, внешний крейт rand (cargo add rand@0.8). Для повторения ввода без траты попытки используйте вложенный loop.

Задача 6. Простые числа и статистика

Сложность: сложное

Дан массив let data = [12, 7, 3, 19, 25, 2, 31, 8, 17, 4]; ([i32; 10]).

  1. Напишите fn is_prime(n: i32) -> bool (проверка делителей до sqrt(n), n < 2 — не простое).
  2. Напечатайте все простые числа из массива.
  3. Найдите минимум, максимум и среднее арифметическое (f64) без использования методов min/max/sum — только цикл.
  4. Выведите индекс и значение максимального элемента в виде max = 31 на позиции 6.

Подсказка: for (i, &x) in data.iter().enumerate(), приведение x as f64, условие остановки d * d <= n.

Глава 2

Ownership, структуры и перечисления

Урок 8 · Часть I. Основы Rust

Что такое ownership

Источник: ch04-01-what-is-ownership.html

Кратко

Ownership (владение) — набор правил, по которым Rust управляет памятью без сборщика мусора и без ручного free. Представьте, что у каждой «вещи» в памяти есть ровно один хозяин-переменная: когда хозяин уходит (конец области видимости), вещь автоматически убирают. Эти правила проверяет компилятор ещё до запуска, поэтому за безопасность памяти вы не платите замедлением в рантайме. Из‑за ownership присваивание вроде let s2 = s1 для String часто перемещает данные, а не копирует их. Поняв move, copy и clone, вы поймёте, почему компилятор иногда «запрещает» использовать старую переменную.

Основное

  • Три правила:

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

2. в каждый момент владелец один;

3. когда владелец выходит из области видимости, значение удаляется (drop).

Это фундамент языка. Не бывает «бесхозных» значений и не бывает двух полноправных владельцев одной и той же кучи данных одновременно (без специальных типов). Когда владелец заканчивается — вызывается drop, память освобождается. Если правила нарушить в коде, программа просто не скомпилируется. Благодаря этому двойное освобождение и использование после free отсекаются заранее.

  • Стек и куча: стек — фиксированный размер, быстрый (LIFO). Куча — динамический размер, доступ через указатель, медленнее. Ownership нужен прежде всего для данных в куче.

Стек как стопка тарелок: положили сверху, сняли сверху — очень быстро. Куча — склад, где размер известен только в рантайме: вы получаете указатель на участок. String держит буфер как раз в куче, поэтому важно знать, кто его освободит. Для простых чисел на стеке правила проще из‑за Copy.

  • String хранит данные в куче: String::from("hi"). Литерал "hi" (&str) зашит в бинарник.

Литерал неизменяем и живёт всё время работы программы — он часть исполняемого файла. String можно менять и строить во время выполнения; под него выделяют память в куче. Путать их опасно: операции вроде push_str есть у String, но не у строкового литерала как у владеющего буфера. Позже срезы (&str) свяжут оба мира.

  • Move (перемещение): let s2 = s1; для String копирует лишь указатель/длину/ёмкость, а s1 становится недействительной. Это защищает от двойного освобождения.

Метаданные String (указатель, len, capacity) невелики и лежат на стеке; сами байты — в куче. При «копировании» как в других языках два указателя смотрели бы на одни данные, и оба вызвали бы drop — катастрофа. Rust делает move: право владения переходит к s2, а s1 использовать нельзя. Если написать println!("{s1}") после move, компилятор выдаст ошибку.

  • Clone: s1.clone() — глубокая копия данных кучи (затратна, явна).

Когда нужна именно независимая копия содержимого, вызывают clone(). Это может выделить новую память и скопировать байты — дороже простого move. Явность важна: в коде видно, где вы сознательно платите за копирование. Случайные дорогие копии так менее вероятны.

  • Copy: типы фиксированного размера на стеке (i32, f64, bool, char, кортежи из Copy-типов) копируются неявно, и оригинал остаётся валидным. Тип с Drop не может быть Copy.

Для let y = x; где x — i32, обе переменные валидны: скопировали биты на стеке, освобождать кучу нечего. Трейт Copy помечает такие типы. Если тип умеет Drop (сложная очистка, как у String), он не может быть Copy — иначе снова возникла бы путаница с освобождением. Правило простое: «дешёвое и простое» копируется само; «владеет ресурсами» — перемещается.

  • Функции: передача значения в функцию — это move (или copy); возврат значения передаёт владение обратно.

Вызов takes_ownership(s) забирает s внутрь функции (для String). После возврата из функции параметр уничтожается, если его не вернули наружу. makes_copy(n) для i32 оставляет исходное n валидным. Вернуть владение можно так: fn f(s: String) -> String { s }. Постоянно гонять значения туда‑сюда неудобно — поэтому существуют ссылки.

  • Постоянно возвращать значения неудобно — для этого есть ссылки (следующий урок).

Паттерн «отдал в функцию → вернул обратно только чтобы пользоваться дальше» громоздкий. Заимствование (&T, &mut T) позволяет дать функции посмотреть или изменить данные без передачи владения. Ownership остаётся правилом номер один; ссылки — способ гибко его соблюдать.

Примеры

fn main() {
    let s1 = String::from("hello");
    let s2 = s1; // move: владение перешло к s2
    // println!("{s1}"); // ошибка: значение перемещено
    println!("{s2}");

    let s3 = s2.clone(); // глубокая копия кучи
    println!("{s2} {s3}"); // оба доступны

    let x = 5;
    let y = x; // Copy: x остаётся валидным
    println!("{x} {y}");
}
fn main() {
    let s = String::from("hi");
    takes_ownership(s);
    // s больше недоступна — владение ушло в функцию

    let n = 5;
    makes_copy(n);
    println!("{n}"); // n доступна благодаря Copy

    let s2 = gives_back(String::from("again"));
    println!("{s2}");
}

fn takes_ownership(s: String) {
    println!("{s}");
} // здесь s удаляется (drop)

fn makes_copy(n: i32) {
    println!("{n}");
}

fn gives_back(s: String) -> String {
    s // владение возвращается вызывающему
}

Важно запомнить

  • У значения один владелец; при выходе владельца из области видимости значение удаляется.
  • Присваивание String обычно делает move, а не глубокую копию.
  • Простые скалярные типы (и подходящие кортежи) реализуют Copy и копируются неявно.
  • Метод clone() явно делает глубокую копию данных в куче и стоит дороже move.

Урок 9 · Часть I. Основы Rust

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

Источник: ch04-02-references-and-borrowing.html

Кратко

Ссылка (&T) позволяет пользоваться значением, не забирая владение — как дать другу посмотреть книгу, не отдавая её насовсем. Такой доступ называют заимствованием (borrowing). Есть неизменяемые ссылки &T (только читать) и изменяемые &mut T (можно менять). Компилятор следит за правилами: либо много читателей, либо один писатель — но не вместе. Благодаря этому гонки данных и висячие указатели отсекаются ещё при компиляции. После изучения ссылок функции перестают выглядеть как «отдал String и получил обратно».

Основное

  • &s — неизменяемая ссылка; через неё значение можно читать, но не менять.

Функция с параметром s: &String может вызвать s.len(), но не s.push_str(...). Владелец (s1) после вызова остаётся валидным — заимствование временно. Если бы владение передавалось, после вызова пришлось бы возвращать значение только ради дальнейшего использования. Неизменяемые ссылки можно создавать несколько штук одновременно.

  • &mut s — изменяемая ссылка; сама переменная должна быть mut.

Чтобы менять данные через ссылку, пишут &mut s, а переменная объявлена как let mut s. Без mut у владельца взять &mut нельзя. Через &mut можно, например, дописать текст в String. Изменяемое заимствование жёстче по правилам — см. ниже.

  • Правила:

1. в любой момент либо одна &mut, либо сколько угодно &;

2. ссылки всегда валидны (не переживают данные).

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

  • Это исключает гонки данных ещё при компиляции.

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

  • NLL: время жизни ссылки заканчивается после её последнего использования, а не конца блока. Поэтому после последнего чтения & можно взять &mut.

Non-Lexical Lifetimes (NLL) — уточнение анализа заимствований. Раньше ссылка «жила» до конца {}, даже если вы её больше не трогали. Теперь компилятор смотрит на последнее реальное использование. Практически: напечатали r1 и r2, и сразу можно взять r3 = &mut s — это законно. Если бы & ещё использовались рядом с &mut, была бы ошибка.

  • Висячие ссылки (dangling) невозможны: функция не может вернуть ссылку на локальную переменную; вернуть нужно владеющее значение (String).

Локальная переменная уничтожается в конце функции. Вернуть & на неё значило бы дать адрес «пустоты». Компилятор такое отклонит (потребуются времена жизни, которых у локали нет). Правильный выход — вернуть String (владение) или принять срез/ссылку извне, которая живёт дольше вызова. Так висячие указатели из классических языков здесь не проходят компиляцию.

  • Операция, обратная ссылке, — разыменование *; для вызова методов Rust делает его автоматически.

Иногда нужно явно написать ref, чтобы получить значение (например, с простыми типами Copy). Но при s.len(), где s: &String, точка сама «проходит» через ссылку — это удобный автоderef. Новичку достаточно знать: методы часто вызываются на ссылках без ручного .

  • Параметры-ссылки обычно записывают как &String/&str, &[T], &mut Vec<T>.

В сигнатурах заимствование видно сразу: функция не забирает данные насовсем. Позже вы узнаете, что &str гибче &String для чтения текста. Для изменения коллекции типичен &mut Vec<T>. Выбор между владением и ссылкой в API — важный дизайн-решение на каждом шаге.

Примеры

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1); // заимствуем, владение остаётся у s1
    println!("'{s1}' имеет длину {len}");

    let mut s = String::from("hello");
    change(&mut s);
    println!("{s}");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

fn change(s: &mut String) {
    s.push_str(", world");
}
fn main() {
    let mut s = String::from("hello");

    let r1 = &s;
    let r2 = &s;
    println!("{r1} {r2}"); // последнее использование r1, r2

    let r3 = &mut s; // можно: неизменяемые ссылки уже не нужны
    r3.push('!');
    println!("{r3}");

    // let a = &mut s;
    // let b = &mut s; // ошибка: две &mut одновременно
}
// fn dangle() -> &String { // ошибка: ссылка на локальные данные
//     let s = String::from("hi");
//     &s
// }

fn no_dangle() -> String {
    String::from("hi") // владение уходит наружу безопасно
}

fn main() {
    println!("{}", no_dangle());
}

Важно запомнить

  • В один момент времени допустима либо одна &mut, либо любое число & — но не смесь.
  • Ссылка не может пережить данные, на которые указывает.
  • Заимствование не передаёт владение: владелец остаётся прежним.
  • Область жизни ссылки фактически заканчивается после последнего использования (NLL).

Урок 10 · Часть I. Основы Rust

Срезы

Источник: ch04-03-slices.html

Кратко

Срез (slice) — это ссылка на непрерывный кусок коллекции: не копия данных, а «окно» в уже существующие байты или элементы. Владения у среза нет: он только заимствует. Внутри хранятся указатель и длина, поэтому срез знает свои границы. Главные типы для новичка — &str (кусок строки в UTF-8) и &[T] (кусок массива или вектора). Срезы решают проблему «голых индексов», которые могут устареть, если данные изменили. Пока срез жив, компилятор не даст испортить исходные данные опасным образом.

Основное

  • Строковый срез: &s[0..5], &s[..5], &s[6..], &s[..]. Границы — в байтах и должны попадать на границы UTF-8 символов, иначе паника.

Синтаксис диапазона выбирает полуинтервал: начало включено, конец в a..b не включён. Важно: индексы считаются в байтах UTF-8, а не в «буквенных» символах. Если разрезать многобайтовый символ посередине, будет паника. Для ASCII это совпадает с «символами», для кириллицы и эмодзи — нет. Поэтому резать строки по байтам нужно осознанно или через безопасные API вроде chars/split.

  • Тип строкового среза — &str. Строковые литералы — тоже &str (срез бинарника), поэтому они неизменяемы.

"hello" имеет тип &str: это срез данных, вшитых в программу. Отсюда неизменяемость литералов. String владеет буфером в куче; &str лишь смотрит на чьи‑то байты (литерал или String). Понимание &str связывает литералы, срезы и функции, которые принимают текст «на чтение».

  • Лучшая сигнатура: fn f(s: &str) принимает и &String (через deref-преобразование), и литерал; &String — менее гибкая.

Если функция объявлена как fn f(s: &String), литерал "hi" напрямую не подойдёт так же удобно. С параметром &str вызывают и f(&my_string), и f("literal"). Deref-преобразование превращает &String в &str автоматически. Book рекомендует &str для чтения текста именно из‑за этой гибкости.

  • Срез заимствует исходные данные: пока срез жив, строку нельзя изменить (clear() требует &mut). Компилятор поймает такую ошибку.

Классический пример Book: нашли первое слово как &str, а потом вызвали s.clear(). Если срез ещё используется, это конфликт заимствований. Раньше с одними индексами i и j такая ошибка была бы логической и проявилась бы позже. Срез «держит» заимствование и делает невалидное состояние некомпилируемым.

  • Срез массива/вектора: &a[1..3] имеет тип &[i32]. Работает как строковый: хранит указатель и длину.

Идея та же: окно в элементы без копирования всего массива. Тип &[T] универсален для любых срезов из массивов и векторов. Функции, принимающие &[T], не обязаны знать, откуда пришли данные — только что есть непрерывная последовательность длины N.

  • Методы &[T]: len(), iter(), first(), last(), contains(), split_at(), to_vec().

Через срез удобно читать длину, перебирать элементы, брать края, искать значение. split_at делит на два среза; to_vec уже копирует данные во владеющий Vec, если нужно владение. Не обязательно учить все методы сразу — достаточно понимать, что срез — полноценный API просмотра, а не «сырой указатель».

  • Срезы позволяют не хранить отдельно индексы, которые могут «протухнуть» при изменении данных.

Пара чисел (start, end) ничего не говорит компилятору о связи со строкой. Срез эту связь выражает типом и правилами заимствования. Если данные изменились так, что старые индексы бессмысленны, подход со срезом либо не скомпилируется, либо заставит пересмотреть код. Это и есть практическая выгода главы о slices в Book.

Примеры

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();
    for (i, &b) in bytes.iter().enumerate() {
        if b == b' ' {
            return &s[..i]; // срез до пробела (в байтах)
        }
    }
    s // всё слово — вся строка
}

fn main() {
    let s = String::from("hello world");
    let hello = &s[0..5];
    let world = &s[6..];
    println!("{hello} {world}");

    println!("{}", first_word(&s));
    println!("{}", first_word("one two")); // литерал тоже &str
}
fn main() {
    let mut s = String::from("hello world");
    let word = first_word(&s);
    // s.clear(); // ошибка: word ещё заимствует s
    println!("{word}");
    s.clear(); // теперь можно — word больше не используется
}

fn first_word(s: &str) -> &str {
    s.split(' ').next().unwrap_or("")
}
fn main() {
    let a = [1, 2, 3, 4, 5];
    let slice: &[i32] = &a[1..3]; // элементы 2 и 3
    assert_eq!(slice, &[2, 3]);
    println!("{} {:?}", slice.len(), slice.first());
}

Важно запомнить

  • &str и &[T] — окна в существующие данные без копирования и без владения.
  • Индексы строкового среза считаются в байтах UTF-8, не в символах Unicode.
  • Параметр &str универсальнее и удобнее, чем &String, для чтения текста.
  • Пока срез жив, исходную строку/коллекцию нельзя изменить конфликтующим образом.

Урок 11 · Часть I. Основы Rust

Структуры

Источник: ch05-01-defining-structs.html

Кратко

Структура (struct) объединяет связанные данные под одним именем типа и именами полей — как анкета: имя, email, активен ли пользователь. Это основной способ создавать собственные типы, понятные и компилятору, и человеку. Экземпляр создают, заполняя поля; читать их можно через точку. Чтобы менять поля, весь экземпляр должен быть mut — отдельных «mut-полей» нет. Пока вы не изучили времена жизни, поля лучше делать владеющими (String), а не ссылками. Для отладки почти всегда добавляют #[derive(Debug)], чтобы печатать структуру целиком.

Основное

  • Определение: struct User { active: bool, name: String, age: u32 }.

Сначала описывают форму данных: какие поля и каких типов. Это ещё не значение, а чертёж. Имя типа — в PascalCase (User, Rectangle). После определения компилятор знает новый тип и будет проверять, что вы заполняете все поля нужными типами. Забыли поле при создании — ошибка компиляции.

  • Создание: User { name: String::from("Ann"), age: 20, active: true }; порядок полей любой.

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

  • Доступ — user.name. Изменять поля можно, только если весь экземпляр mut (отдельных mut-полей нет).

Чтение: println!("{}", user.name). Запись: нужен let mut user = ..., затем user.email = .... Нельзя пометить одно поле изменяемым, а остальные нет: изменяемость — свойство всей переменной. Это упрощает рассуждения о заимствованиях целого значения.

  • Field init shorthand: если имя переменной совпадает с полем: User { name, age, active: true }.

Вместо name: name пишут просто name. Меньше шума в конструкторах вроде build_user(name, email). Если имена не совпадают, полная форма field: value обязательна. Это синтаксический сахар, на семантику владения не влияет.

  • Update syntax: User { name: new_name, ..old } берёт остальные поля из old. Это move для неCopy-полей: old частично (или полностью) становится недоступной.

Удобно создать почти такую же структуру с одним-двумя изменениями. Важно помнить ownership: поля вроде String перемещаются из old в новый экземпляр. После этого old может стать частично перемещённой и недоступной целиком. Copy-поля (например, bool) копируются, но из‑за перемещённых String старый экземпляр всё равно часто нельзя использовать как раньше.

  • Кортежные структуры: struct Color(i32, i32, i32); — разные типы даже при одинаковых полях; доступ c.0.

Это именованный кортеж: поля без имён, но тип свой. Color и Point с одинаковыми тремя i32 — разные типы, их нельзя случайно перепутать в сигнатурах. Доступ как у кортежа: .0, .1, .2. Подходят, когда имена полей мало что добавляют, но нужна отдельная идентичность типа.

  • Unit-подобные структуры: struct AlwaysEqual; без полей, для типажей.

Выглядят как тип «без данных». Нужны, когда важен сам тип (например, для реализации trait), а хранить внутри нечего. Экземпляр создают просто AlwaysEqual. На раннем этапе Book они встречаются реже обычных структур с полями.

  • Поля стоит делать владеющими (String, а не &str), пока не изучены времена жизни.

Ссылка в поле требует указать, как долго она валидна ('a и т.д.). Без этого знания проще хранить String: структура сама владеет текстом и живёт независимо. Позже, для эффективности, поля-ссылки появятся осознанно. Новичкам Book советует не усложнять раньше времени.

  • Отладка: #[derive(Debug)] позволяет печатать {:?} и {:#?}; макрос dbg!(&x) печатает выражение, файл и строку в stderr.

Без Debug println!("{:?}", user) не скомпилируется. {:#?} даёт «красивый» многострочный вывод. dbg! удобен при отладке: показывает, где сработало и какое значение, и возвращает владение/значение дальше по коду. Для пользовательского вывода позже пишут свой Display; для разработки хватает Debug.

Примеры

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

fn build_user(name: String, email: String) -> User {
    // shorthand: имена переменных совпадают с полями
    User { name, email, active: true }
}

fn main() {
    let mut u1 = build_user(String::from("Ann"), String::from("ann@example.com"));
    u1.email = String::from("new@example.com"); // нужен mut у всего экземпляра

    let u2 = User {
        name: String::from("Bob"),
        ..u1 // остальные поля переезжают из u1 (move)
    };
    println!("{:#?}", u2);
}
struct Color(i32, i32, i32);
struct Point(i32, i32, i32); // другой тип, даже с теми же полями

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

fn area(r: &Rectangle) -> u32 {
    r.width * r.height // заимствуем, не забираем владение
}

fn main() {
    let black = Color(0, 0, 0);
    let origin = Point(0, 0, 0);
    println!("{} {}", black.0, origin.2);

    let rect = Rectangle { width: 30, height: 50 };
    println!("{:?} площадь = {}", rect, area(&rect));
    dbg!(&rect);
}

Важно запомнить

  • Изменяемость задаётся для всего экземпляра (let mut), а не для отдельных полей.
  • Синтаксис ..other перемещает неCopy-поля из старого экземпляра.
  • Кортежная структура создаёт новый самостоятельный тип, даже если набор полей совпадает с другой.
  • Атрибут #[derive(Debug)] нужен, чтобы печатать структуру через {:?} или dbg!.

Урок 12 · Часть I. Основы Rust

Методы

Источник: ch05-03-method-syntax.html

Кратко

Методы — это функции, которые «принадлежат» типу: структуре, перечислению или другому типу. Их пишут внутри блока impl ИмяТипа { ... }. Первый параметр метода почти всегда связан с экземпляром: self, &self или &mut self. Это похоже на методы в других языках, только в Rust явно видно, берёте ли вы значение во владение или только смотрите/меняете через ссылку. Функции внутри impl без self называют ассоциированными: они относятся к типу, а не к одному объекту — типичный пример — конструктор new. Вызов метода выглядит привычно: rect.area(), а ассоциированную функцию вызывают через ::: Rectangle::square(3).

Основное

  • Блок impl Тип { ... } содержит методы и ассоциированные функции.

Здесь вы «прикрепляете» поведение к типу: площадь прямоугольника, масштабирование, создание квадрата. Один тип может иметь несколько блоков impl — это удобно, если код разбит по файлам или по смыслу. Без impl у структуры были бы только поля; методы делают тип удобным в использовании.

  • Формы self:

- &self — чтение (самая частая);

- &mut self — изменение;

- self — забирает владение (например, преобразование в другой тип).

Выбор формы говорит компилятору, что можно делать с объектом после вызова. С &self объект остаётся доступен — метод только «смотрит». С &mut self можно менять поля, но нужна изменяемая переменная. С self объект «съедается»: после вызова его больше нельзя использовать — так делают, когда превращают значение в другое или разбирают его на части. Ошибка в выборе часто даёт сообщение вроде «value borrowed as mutable» или «use of moved value».

  • Self — псевдоним типа в impl.

Внутри impl Rectangle имя Self означает Rectangle. Это удобно в возвращаемых типах конструкторов: fn square(...) -> Self. Если позже переименуете тип, сигнатуры методов с Self менять не придётся. По смыслу это «тот же тип, для которого сейчас пишем impl».

  • Вызов: rect.area(). Rust сам добавляет &, &mut или * (automatic referencing/dereferencing).

Вам не нужно писать (&rect).area() или разыменовывать вручную, как в C++ с ->. Компилятор смотрит на сигнатуру метода и подстраивает вызов. Это снижает шум в коде и меньше шансов запутаться со ссылками при обычных вызовах. Если метод требует &mut self, переменная всё равно должна быть mut.

  • Ассоциированные функции: без self, вызываются через ::: Rectangle::square(3), String::from(...). Обычно — конструкторы new.

Они относятся к типу целиком, а не к уже созданному экземпляру. Аналогия: фабрика или статический метод. По соглашению конструктор часто называют new, но это не ключевое слово — просто привычное имя. Если вызвать такую функцию как метод у значения, компилятор не примет это так же, как обычный метод с self.

  • Метод может иметь то же имя, что и поле (часто — геттеры).

Например, поле value и метод fn value(&self) -> u32. При обращении c.value — это поле, c.value() — метод. Так делают, когда поле приватное, а снаружи показывают только чтение. Без скобок и со скобками — разные вещи; новички иногда путают их и получают неожиданный тип.

  • Дополнительные параметры идут после self.

Сигнатура всегда начинается с получателя (self / &self / &mut self), затем остальные аргументы: fn can_hold(&self, other: &Rectangle). Это фиксированный порядок в Rust. Если поставить self не первым, код не скомпилируется. Дополнительные параметры подчиняются тем же правилам владения и заимствования, что и обычные функции.

Примеры

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

impl Rectangle {
    // Ассоциированная функция: конструктор квадрата
    fn square(size: u32) -> Self {
        Self { width: size, height: size }
    }

    // Только чтение — достаточно &self
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }

    // Нужно менять поля — &mut self
    fn scale(&mut self, k: u32) {
        self.width *= k;
        self.height *= k;
    }
}

fn main() {
    let mut r = Rectangle { width: 30, height: 50 };
    let sq = Rectangle::square(10); // вызов через ::
    println!("{}", r.area());
    println!("{}", r.can_hold(&sq));
    r.scale(2);
    println!("{:?}", r);
}
struct Counter {
    value: u32,
}

impl Counter {
    fn new() -> Self {
        Counter { value: 0 }
    }
    // Геттер с тем же именем, что у поля
    fn value(&self) -> u32 {
        self.value
    }
    // self без ссылки — забирает владение
    fn into_value(self) -> u32 {
        self.value
    }
}

fn main() {
    let c = Counter::new();
    println!("{}", c.value());
    println!("{}", c.into_value());
    // c больше недоступен: владение уже отдано
}

Важно запомнить

Методы живут в impl и обычно принимают &self для чтения или &mut self для изменения. Форма self без ссылки потребляет значение — после такого вызова экземпляр нельзя использовать снова. Функции без self — ассоциированные и вызываются как Тип::функция(). При вызове метода Rust сам подставляет нужные ссылки, поэтому синтаксис остаётся простым: obj.method(). Конструктор по соглашению часто называют new, но это лишь привычное имя, а не требование языка.

Урок 13 · Часть I. Основы Rust

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

Источник: ch06-01-defining-an-enum.html

Кратко

Перечисление (enum) описывает значение, которое в каждый момент является ровно одним из нескольких вариантов. Это как светофор: красный, жёлтый или зелёный — но не «всё сразу». Варианты могут быть пустыми или хранить данные разного вида: числа, строки, структуры. В стандартной библиотеке есть особенно важный enum — Option<T>: он заменяет null из других языков и заставляет явно сказать, что делать, если значения нет. Благодаря этому многие ошибки «обратились к пустоте» ловятся ещё на этапе компиляции.

Основное

  • Определение: enum IpAddrKind { V4, V6 }; обращение — IpAddrKind::V4.

Каждый вариант — отдельное «имя» внутри одного типа. К варианту всегда обращаются через имя enum и ::, чтобы было ясно, откуда он. Если забыть префикс, компилятор не найдёт имя в области видимости. Такой простой enum удобен, когда важны только метки без данных.

  • Данные в вариантах: разные варианты хранят разные типы и количество полей:

enum Message { Quit, Move { x: i32, y: i32 }, Write(String), Color(i32, i32, i32) }.

Один вариант может ничего не нести (Quit), другой — именованные поля как у структуры (Move), третий — кортеж (Write(String)). Это мощнее, чем «enum только из чисел» в C: данные и вид значения связаны вместе. Неверный набор полей у варианта — ошибка компиляции, а не сюрприз в рантайме.

  • Каждый вариант — как отдельная структура, но всё вместе — один тип; поэтому его можно передавать в одну функцию.

Функция может принимать Message и обрабатывать любой вариант через match. Не нужно плодить отдельные типы и потом как-то их объединять вручную. Если бы это были разные несвязанные типы, одну функцию с общей логикой написать было бы сложнее. Enum держит «семейство родственных значений» под одним именем типа.

  • Как и у структур, у enum бывают методы через impl.

Можно написать impl Message { fn call(&self) { ... } } и вызывать msg.call(). Методы часто содержат match по self, чтобы для каждого варианта сделать своё действие. Без методов вся логика размазывается по внешним функциям; с impl поведение живёт рядом с типом.

  • Option<T>: enum Option<T> { None, Some(T) }. Входит в prelude (Some, None доступны без префикса). Выражает «значение может отсутствовать».

Some(5) — «есть число 5», None — «ничего нет». Это стандартный способ сказать «возможно, пусто» без специального «пустого» значения внутри T. Имена Some и None уже импортированы, поэтому писать Option::Some обычно не нужно. Почти весь идиоматичный Rust опирается на Option вместо nullable-указателей.

  • Option<T> и T — разные типы: нужно явно обработать None, поэтому нулевых указателей (billion-dollar mistake) нет.

Нельзя случайно передать Option<i32> туда, где ждут i32: компилятор остановит. В языках с null отсутствие значения часто маскируется под обычный объект и «взрывается» поздно. В Rust отсутствие — отдельный случай, который нужно разобрать. Цена — чуть больше кода; выигрыш — меньше скрытых падений.

  • Извлечение значения — match, if let, unwrap_or, map и др.

Чтобы получить T из Option<T>, нужно выбрать стратегию: разобрать оба случая, взять значение по умолчанию, преобразовать содержимое и т. д. unwrap достанет значение или запаникует — для обучения и прототипов иногда удобно, в надёжном коде чаще избегают. Методы вроде map позволяют работать с содержимым, не выходя из Option.

  • Тип Option<i32> нельзя сложить с i32 напрямую.

Выражение 1 + some_number не скомпилируется, если some_number имеет тип Option<i32>. Сначала нужно решить, что делать с None — например, some_number.unwrap_or(0) + 1. Это не ограничение «для усложнения», а защита от сложения «числа с пустотой». Пока вы явно не обработали отсутствие, арифметика недоступна.

Примеры

#[derive(Debug)]
enum IpAddr {
    V4(u8, u8, u8, u8), // данные — четыре байта
    V6(String),
}

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
}

impl Message {
    fn call(&self) {
        match self {
            Message::Quit => println!("quit"),
            Message::Move { x, y } => println!("move {x},{y}"),
            Message::Write(s) => println!("{s}"),
        }
    }
}

fn main() {
    let home = IpAddr::V4(127, 0, 0, 1);
    let lo = IpAddr::V6(String::from("::1"));
    println!("{:?} {:?}", home, lo);
    Message::Write(String::from("hi")).call();
}
fn main() {
    let some_number: Option<i32> = Some(5);
    let absent: Option<i32> = None;

    // let sum = 1 + some_number; // ошибка: Option<i32> != i32
    println!("{}", some_number.unwrap_or(0) + 1);
    println!("{}", absent.unwrap_or(0));
    println!("{:?}", some_number.map(|n| n * 2)); // Some(10)
}

Важно запомнить

enum означает «ровно один из нескольких вариантов», и варианты могут нести свои данные. В Rust нет null: отсутствие значения выражают через Option<T>. Прежде чем использовать содержимое как обычный T, Option нужно осознанно распаковать или обработать. Методы через impl работают для перечислений так же, как для структур.

Урок 14 · Часть I. Основы Rust

match и if let

Источник: ch06-02-match.html

Кратко

match — это мощный «переключатель» по значению: вы перечисляете шаблоны, и выполняется ветка, которая подошла. В отличие от switch во многих языках, match в Rust обязан рассмотреть все возможные случаи — иначе программа не скомпилируется. Это особенно полезно с enum и Option: забытый вариант сразу виден. Когда вас интересует только один шаблон, а остальное неважно, пишут короткий if let. Есть ещё let ... else — удобно для раннего выхода, если нужный вариант не подошёл.

Основное

  • Синтаксис: match значение { шаблон => выражение, ... }. match — выражение; все ветки одного типа.

Результат match можно присвоить переменной или вернуть из функции. Все ветки должны давать значения одного типа — иначе компилятор не поймёт тип всего выражения. Если веткам не нужно возвращать полезное значение, часто используют () или только побочные эффекты вроде println!. Ошибка «branches have incompatible types» как раз про это.

  • Ветка может быть блоком {}.

Когда логики больше одной строки — фигурные скобки. Последнее выражение в блоке без точки с запятой — результат ветки. Так можно и напечатать что-то, и вернуть число. Без блока сложные ветки читать трудно; с блоком структура остаётся ясной.

  • Привязка значений: шаблон Coin::Quarter(state) извлекает данные варианта.

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

  • Option<T>: Some(i) => Some(i + 1), None => None.

Типичный приём: обработать оба случая и снова вернуть Option. Так функции вроде «увеличить, если есть» остаются безопасными. Если забыть ветку None, компилятор укажет на неисчерпывающий match. Это одна из главных причин, почему Option хорошо сочетается с match.

  • Исчерпываемость: если какой-то вариант не покрыт — ошибка компиляции.

Добавили новый вариант в enum — все match без _ нужно обновить. Это свойство, а не помеха: рефакторинг не оставляет «мёртвых» необработанных случаев. В языках без исчерпывающей проверки такие баги всплывают только в рантайме.

  • Универсальные ветки: other => ... (с привязкой) или _ => ... (без привязки); ставятся последними. _ => () — ничего не делать.

_ означает «всё остальное, имя не нужно». other (или любое имя) ловит остаток и даёт к нему доступ. Такие ветки должны быть в конце: иначе более общие шаблоны «перекроют» частные, и до них не дойдёт управление. Если поставить _ слишком рано, компилятор может предупредить о недостижимых ветках.

  • Дополнительно в шаблонах: 1 | 2, диапазоны 1..=5, guard Some(n) if n > 0.

| объединяет несколько вариантов в одну ветку. Диапазон 1..=5 включает границы. Guard — дополнительное условие после if; если оно ложно, проверяется следующая ветка. Это делает match выразительным без цепочки вложенных if. Важно помнить: guard не отменяет нужду в исчерпываемости по шаблонам.

  • if let шаблон = значение { } else { } — меньше кода, но нет проверки исчерпываемости.

Удобно, когда важен только Some(...) или один вариант enum, а остальное обрабатываете одинаково (или никак). Минус: компилятор не напомнит про новые варианты. Аналогия: вместо полной таблицы случаев — короткая проверка «если это именно то, что нужно». Для одного интересного случая код обычно читается легче, чем полный match.

  • let ... else: let Some(x) = opt else { return; }; — раннее завершение.

Если шаблон совпал, x доступен дальше в обычном коде. Если нет — выполняется блок else, который должен «уйти» из текущей области (return, break, panic! и т. п.). Это убирает лишнее вложение if let. Хорошо подходит для функций, где без нужного значения продолжать бессмысленно.

  • Выбор: нужны все варианты — match; один — if let.

Полный разбор и будущие изменения enum — берите match. Разовый «достать, если есть» — if let или let else. Неверный выбор не ломает корректность всегда, но влияет на ясность и на то, поймает ли компилятор пропущенный случай. На практике оба инструмента используют рядом в одном проекте.

Примеры

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
}

enum Coin {
    Penny,
    Nickel,
    Quarter(UsState),
}

fn value_in_cents(coin: &Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Quarter(state) => {
            // state извлечён из варианта
            println!("штат: {:?}", state);
            25
        }
    }
}

fn main() {
    println!("{}", value_in_cents(&Coin::Quarter(UsState::Alaska)));
    println!("{}", value_in_cents(&Coin::Penny));
    let _ = (Coin::Nickel, UsState::Alabama);
}
fn plus_one(x: Option<i32>) -> Option<i32> {
    match x {
        None => None,
        Some(i) => Some(i + 1),
    }
}

fn main() {
    println!("{:?} {:?}", plus_one(Some(5)), plus_one(None));

    let n = 7u8;
    match n {
        1 | 2 => println!("мало"),
        3..=9 => println!("средне"),
        _ => println!("много"),
    }

    let config_max = Some(3u8);
    if let Some(max) = config_max {
        println!("max = {max}");
    } else {
        println!("не задано");
    }

    // Если не Some — сразу выходим из main
    let Some(v) = config_max else { return };
    println!("{v}");
}

Важно запомнить

match должен покрыть все возможные случаи; иначе код не скомпилируется. Ветка _ означает «всё остальное» и ставится в конце. if let — короткий вариант, когда важен один шаблон, но без гарантии исчерпываемости. Сам match — выражение: все ветки возвращают значения одного типа, и результат можно использовать дальше в коде.

Практика 2 · Часть I. Основы Rust

ownership, структуры, перечисления

Задания для закрепления темы · решений нет

Темы: владение, ссылки, срезы, структуры, методы, enum, match, if let (уроки 02-01 … 02-07).

Задача 1. Владение и клонирование

Сложность: лёгкое

Дан код:

fn main() {
    let s1 = String::from("rust");
    let s2 = s1;
    println!("{s1} {s2}");
}
  1. Объясните в комментарии, почему он не компилируется.
  2. Исправьте двумя способами: через clone() и через ссылку &.
  3. Покажите, что let a = 5; let b = a; работает без clone, и объясните почему (Copy).

Задача 2. Длина и изменение через ссылки

Сложность: лёгкое

Напишите функции:

  • fn count_chars(s: &str) -> usize — число символов (chars().count()), не байтов;
  • fn add_exclamation(s: &mut String) — добавляет ! в конец.

В main вызовите обе для String::from("Привет") и выведите длину в байтах (len()) и символах.

Ожидаемо: 12 байт, 6 символов, строка Привет!.

Задача 3. Первое и последнее слово

Сложность: среднее

Напишите fn first_and_last(s: &str) -> (&str, &str), возвращающую срезы первого и последнего слова (слова разделены пробелами). Для пустой строки верните ("", "").

Пример: "раз два три четыре" → ("раз", "четыре").

Подсказка: split_whitespace(), next(), last(), unwrap_or(""). Срезы ссылаются на исходную строку — копировать не нужно.

Задача 4. Структура Rectangle

Сложность: среднее

Создайте struct Rectangle { width: u32, height: u32 } с #[derive(Debug)] и методами:

  • new(width, height) -> Self;
  • square(size) -> Self;
  • area(&self) -> u32;
  • perimeter(&self) -> u32;
  • can_hold(&self, other: &Rectangle) -> bool;
  • scale(&mut self, k: u32).

В main создайте прямоугольник 30×50 и квадрат 10, выведите площади, проверьте can_hold и результат scale(2) через {:#?}.

Задача 5. Банковский счёт

Сложность: сложное

Создайте struct Account { owner: String, balance: u64 } и enum Transaction { Deposit(u64), Withdraw(u64), Transfer { to: String, amount: u64 } }.

Реализуйте fn apply(&mut self, tx: &Transaction) -> Option<u64>:

  • Deposit увеличивает баланс;
  • Withdraw и Transfer уменьшают, но если денег недостаточно — возвращают None и баланс не меняют;
  • при успехе возвращают Some(новый_баланс).

В main примените список из 4–5 транзакций (Vec<Transaction>), для каждой выведите результат с помощью match (Some(b) => ..., None => ...).

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

Задача 6. Фигуры и калькулятор

Сложность: сложное

Определите enum Shape { Circle { radius: f64 }, Rect { w: f64, h: f64 }, Triangle(f64, f64, f64) }.

  1. Метод area(&self) -> Option<f64>: для треугольника по формуле Герона; если стороны не образуют треугольник (a + b <= c и т. п.) — None.
  2. Функция fn largest(shapes: &[Shape]) -> Option<&Shape> — фигура с наибольшей площадью (фигуры без площади пропускайте).
  3. В main создайте вектор из 4–5 фигур (включая некорректный треугольник) и выведите самую большую с помощью if let Some(s) = largest(&shapes).

Подсказка: std::f64::consts::PI; сравнение через partial_cmp, либо ручной цикл с переменной best: Option<(&Shape, f64)>.

Глава 3

Модули, коллекции и ошибки

Урок 15 · Часть I. Основы Rust

Пакеты, крейты и модули

Источник: ch07-02-defining-modules-to-control-scope-and-privacy.html

Кратко

Модульная система Rust раскладывает код по уровням: пакет (package) — то, что описывает Cargo.toml; внутри пакета живут крейты (crate) — единицы компиляции; крейт делится на модули (module). Модули задают дерево имён и правила видимости: что можно вызывать снаружи, а что спрятано. По умолчанию почти всё приватно — это помогает держать внутренности библиотеки под контролем. Пути вроде crate::a::b показывают, где искать имя, а use создаёт короткий ярлык, чтобы не писать длинный путь каждый раз.

Основное

  • Крейт — единица компиляции: бинарный (src/main.rs) или библиотечный (src/lib.rs). Пакет — то, что описывает Cargo.toml; содержит 0–1 библиотеку и любое число бинарников (src/bin/*.rs).

Аналогия: пакет — проект целиком, крейт — конкретная собираемая «программа» или «библиотека» внутри него. Бинарный крейт даёт исполняемый файл с main, библиотечный — код для переиспользования другими. Если перепутать, куда класть общий код, его будет сложно импортировать. Cargo связывает всё через манифест Cargo.toml.

  • Модуль: mod name { ... } или в отдельном файле: mod name; ищет src/name.rs либо src/name/mod.rs. Вложенные: src/name/child.rs.

Объявление mod name; говорит компилятору: «подключи модуль из файла». Сам файл без такого объявления в родителях в сборку не попадёт — частая ловушка новичков. Вложенность модулей отражает вложенность папок и логическую структуру API. Большой код без модулей превращается в один неразборчивый файл.

  • Пути: абсолютный (crate::a::b) и относительный (self::, super::, имя в текущем модуле).

Абсолютный путь начинается от корня текущего крейта (crate::). Относительный — от текущего места: self — здесь, super — на уровень выше. Неверный путь даёт ошибку «не найдено в этом scope». Хорошие пути делают зависимости между частями кода явными и предсказуемыми.

  • Приватность: всё приватно по умолчанию. Родители не видят приватное в потомках, потомки видят приватное у предков. pub открывает элемент.

Это наоборот многим языкам, где всё публично, пока не спрячешь. Приватность по умолчанию уменьшает случайное «протекание» внутренней логики. Если снаружи не видите функцию — скорее всего, забыли pub или путь идёт не через публичные модули. Потомки могут опираться на детали предков; родители — нет, иначе инкапсуляция ломается.

  • pub struct — поля остаются приватными, если не помечены pub. pub enum — все варианты публичны.

Открыть структуру ≠ открыть все её поля: иначе снаружи можно было бы нарушить инварианты (например, создать «завтрак» без нужных данных). Для полей pub ставят отдельно. У enum другая договорённость: раз тип публичен, все варианты тоже публичны — иначе с ним почти нельзя работать через match. Путаница struct/enum здесь — частый источник сюрпризов.

  • super:: — подъём на уровень выше.

Из вложенного модуля можно вызвать функцию родителя через super::helper(). Это короче и устойчивее, чем длинный абсолютный путь, когда связь именно «с родителем». Если модуль перенести глубже, super всё ещё указывает на непосредственного родителя. Злоупотреблять им не стоит: слишком много super::super:: намекает, что структуру модулей пора упростить.

  • use создаёт ярлык: use crate::a::b::func;. Идиома: для функций импортируют родительский модуль, для структур/enum — сам тип.

После use имя доступно коротко, код не копируется. Для функций часто пишут use restaurant::hosting; и вызывают hosting::seat(), чтобы было видно происхождение. Типы обычно импортируют целиком: use std::collections::HashMap;. Без use пришлось бы каждый раз писать полный путь — это быстро утомляет.

  • as: use std::io::Result as IoResult;. Вложенные пути: use std::{cmp::Ordering, io};. Glob: use std::collections::*; (избегать).

as помогает, когда два типа называются одинаково (например, разные Result). Вложенные пути сокращают повторы use. Glob импортирует всё подряд и ухудшает читаемость: непонятно, откуда имя. В обычном коде glob почти не используют; исключение — тесты или реэкспорт в особых случаях.

  • pub use — реэкспорт. Внешние пакеты — в [dependencies], пути начинаются с имени крейта.

Реэкспорт показывает внутренний путь как часть публичного API: удобно дать пользователям короткий импорт. Зависимости из crates.io (и локальные) прописывают в Cargo.toml, затем обращаются имя_крейта::.... Если зависимость не указана, путь «из ниоткуда» не заработает. Так Cargo чётко отделяет свой код от чужого.

Примеры

mod restaurant {
    pub mod hosting {
        pub fn seat() {
            println!("посадка");
        }
        fn secret() {} // снаружи модуля недоступна
    }

    pub struct Breakfast {
        pub toast: String,
        seasonal_fruit: String, // приватное поле
    }

    impl Breakfast {
        pub fn summer(toast: &str) -> Breakfast {
            Breakfast {
                toast: String::from(toast),
                seasonal_fruit: String::from("персики"),
            }
        }
    }

    pub fn serve() {
        super::helper(); // функция на уровень выше
        self::hosting::seat();
    }
}

fn helper() {
    println!("helper");
}

use restaurant::hosting;

fn main() {
    hosting::seat();
    restaurant::serve();
    let mut b = restaurant::Breakfast::summer("ржаной");
    b.toast = String::from("пшеничный");
    // b.seasonal_fruit недоступно снаружи
    println!("{}", b.toast);
}
// src/lib.rs
pub mod network;      // подключает src/network.rs
mod util;             // src/util.rs (приватный модуль)

pub use network::connect; // реэкспорт наружу крейта

Важно запомнить

По умолчанию всё приватно; чтобы открыть элемент снаружи, нужен pub. Объявление mod x; подключает файл модуля — без него файл сам по себе в сборку не входит. use только добавляет короткое имя в область видимости и не копирует код. У публичной структуры поля всё равно нужно помечать pub по отдельности; у публичного enum варианты открыты сразу.

Урок 16 · Часть I. Основы Rust

Vec и String

Источник: ch08-01-vectors.html

Кратко

Vec<T> — это растущий список значений одного типа, данные которого лежат в куче. Можно добавлять и убирать элементы, не зная размер заранее — как динамический массив. String — тоже владеющий, растущий буфер, но для текста в кодировке UTF-8. Оба типа сами управляют памятью: когда вектор или строка уничтожаются, их данные освобождаются. Важно помнить: у String «длина» и индексация связаны с байтами, а не с привычными «символами как в блокноте». Подробнее о строках: https://doc.rust-lang.org/book/ch08-02-strings.html

Основное

Vec

  • Создание: Vec::new() (с типом) или vec![1, 2, 3].

Пустой вектор часто пишут с аннотацией типа: компилятору нужно знать T. Макрос vec! удобен для начальных значений и выводит тип из элементов. Без типа у полностью пустого Vec::new() компилятор может «не понять», что хранить. Выбор между ними — вкус и ситуация: пустой буфер или сразу список.

  • push, pop, insert, remove, len, is_empty, clear.

Это базовый набор операций изменения и запроса размера. push добавляет в конец, pop снимает с конца (и возвращает Option). insert/remove по индексу сдвигают элементы и поэтому дороже на больших векторах. clear опустошает, но обычно сохраняет выделенную ёмкость. Неверный индекс у remove приведёт к панике.

  • Доступ: v[i] — паника при выходе за границы; v.get(i) → Option<&T>.

Индексация удобна, когда индекс точно валиден. get безопаснее для «может не быть»: получите Some или None и обработаете. Выход за границы через [] — типичная паника новичков. Аналогия: [] — «дай или урони программу», get — «дай или скажи, что нет».

  • Нельзя держать & на элемент и вызывать push (возможно перевыделение памяти).

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

  • Обход: for x in &v, изменение — for x in &mut v { *x += 1; }.

Итерация по &v не забирает владение вектором — его можно использовать после цикла. Для изменения элементов нужен &mut v, а внутри — разыменование *x. Цикл for x in v переместит вектор (для не-Copy типов) и «съест» его. Выбор формы обхода — часть правил владения.

  • Разные типы в одном векторе — через enum.

Vec хранит элементы одного типа T. Если нужны «число или строка», сделайте enum с вариантами и положите Vec<ThatEnum>. Иначе система типов не сойдётся. Это тот же приём, что и для других однородных коллекций в Rust: единство типа сохраняется, разнообразие — внутри enum.

String

  • String::new(), "abc".to_string(), String::from("abc").

Все три создают владеющую строку в куче. Литерал "abc" имеет тип &str — это срез, он не владеет буфером. Чтобы наращивать и хранить текст как владельца, нужен String. Путаница &str и String — одна из первых сложностей; помните: &str смотрит, String владеет.

  • Добавление: push_str(&str), push(char), + (забирает левый операнд, правый — &str), format! (не забирает владение).

push_str и push меняют существующую строку. Оператор + удобен, но перемещает левый String, поэтому левый операнд после сложения обычно недоступен. format! собирает новую строку и не забирает аргументы — часто самый понятный способ склейки. Неверный выбор приводит к ошибкам move или к лишним копиям.

  • Индексация s[0] запрещена: строка — UTF-8, символ занимает 1–4 байта, индекс не соответствует символу.

В "Зд" буква «З» занимает не один байт. Если бы s[0] возвращал «первый символ», ответ был бы двусмысленным и медленным. Поэтому Rust просто не даёт индексную операцию по числу для String/str. Нужны chars(), срезы по байтовым границам или специальные крейты для графем.

  • Три взгляда: байты (bytes()), скалярные значения (chars()), графемы (в std нет).

Байты — сырой UTF-8. chars() — Unicode scalar values (примерно «буквы», но не всегда то, что видит пользователь: сложные эмодзи и акценты сложнее). Графемные кластеры («то, что выглядит как один символ») в стандартной библиотеке не входят — для них берут отдельные крейты. Выбор взгляда зависит от задачи: протокол, длина текста, отрисовка.

  • Срезы &s[0..4] — по байтам, на границе символа, иначе паника.

Диапазон считает байты, не «буквы». Если разрезать символ посередине, будет паника. Поэтому срезы безопасны только когда вы уверены в границах (например, после проверки или на ASCII). Для пользовательского текста чаще итерируют chars(), а не режут наугад.

  • len() — число байт, не символов.

У строки "Здравствуйте" len() вернёт 24, а не 12. Для числа символов (scalar values) используют chars().count(). Если показать len() пользователю как «длину слова», получится путаница. В сетевых протоколах и буферах байтовая длина как раз нужна — контекст решает.

Примеры

fn main() {
    let mut v: Vec<i32> = Vec::new();
    v.push(1);
    v.push(2);
    let mut w = vec![10, 20, 30];

    match w.get(5) {
        Some(x) => println!("{x}"),
        None => println!("нет элемента"),
    }
    for x in &mut w {
        *x += 1; // меняем каждый элемент
    }
    println!("{:?} {:?} {}", v, w, w[0]);

    enum Cell {
        Int(i32),
        Text(String),
    }
    let row = vec![Cell::Int(3), Cell::Text(String::from("blue"))];
    println!("{}", row.len());
}
fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
    s.push('!');

    let s1 = String::from("Hello, ");
    let s2 = String::from("world");
    let s3 = s1 + &s2; // s1 перемещена в s3
    println!("{s3}");

    let t = format!("{}-{}-{}", "a", "b", "c");
    println!("{t}");

    let hello = "Здравствуйте";
    println!("{}", hello.len()); // 24 байта
    println!("{}", hello.chars().count()); // 12 символов
    println!("{}", &hello[0..4]); // "Зд" — корректные байтовые границы
    for c in "Зд".chars() {
        println!("{c}");
    }
}

Важно запомнить

Индексация v[i] паникует при неверном индексе, а v.get(i) возвращает Option и позволяет обработать отсутствие. Пока жива ссылка на элемент вектора, вызывать push нельзя — возможное перевыделение памяти сделало бы ссылку недействительной. String нельзя индексировать числом вроде s[0]; работают chars(), bytes() и срезы по безопасным байтовым границам. Метод len() у строки считает байты UTF-8, а не количество видимых букв.

Урок 17 · Часть I. Основы Rust

HashMap

Источник: ch08-03-hash-maps.html

Кратко

HashMap<K, V> — словарь: по ключу быстро находите значение. Все ключи одного типа K, все значения — одного типа V. Данные хранятся в куче, как у Vec и String. Порядок обхода элементов не обещан: сегодня ключи могут идти так, завтра иначе — на это нельзя опираться. Для частого «вставить, если ещё нет» удобен Entry API. Ключ должен уметь сравниваться и хешироваться (Eq + Hash).

Основное

  • Не входит в prelude: use std::collections::HashMap;.

В отличие от Vec и String, словарь нужно импортировать явно. Без use придётся писать полный путь или получить ошибку «не найдено». Это намеренно: коллекций много, в prelude держат самое базовое. Первая строка почти любого файла с картами — этот импорт.

  • Создание: HashMap::new(); из итератора: vec_of_pairs.into_iter().collect() или HashMap::from([("a", 1), ("b", 2)]).

Пустую карту наполняют через insert. Из списка пар можно собрать карту через collect, если типы сходятся. HashMap::from удобен для небольших фиксированных наборов. Пустая карта без использования может потребовать аннотации типов HashMap<K, V>.

  • Вставка: map.insert(k, v) — при существующем ключе заменяет значение.

Повторный insert с тем же ключом перезаписывает старое значение (и возвращает предыдущее как Option). Если нужна логика «не трогать, если уже есть», insert не подходит — берите entry. Случайная перезапись — частый баг, когда ключи повторяются из входных данных.

  • Чтение: map.get(&key) → Option<&V>; часто .copied().unwrap_or(0). contains_key(&k). Индекс map[&k] паникует при отсутствии.

get безопасен: ключа может не быть. Индексация [] удобна, только если отсутствие — действительно баг. contains_key отвечает «есть ли ключ», не доставая значение. Для типов Copy цепочка copied().unwrap_or(...) даёт уже значение, а не ссылку.

  • Удаление: remove(&k).

Удаляет пару и возвращает бывшее значение как Option, если ключ был. После удаления ключ в карте больше не находится. Удаление несуществующего ключа — не паника, а None. Это обычная часть жизненного цикла словаря.

  • Владение: типы Copy копируются; String перемещается в карту. Ссылки допустимы, только если данные живут дольше карты.

Когда вставляете String, владение уходит в HashMap — исходную переменную дальше не использовать. Числа обычно копируются. Карта ссылок (HashMap<&str, _>) валидна, пока строковые данные живы; иначе borrow checker остановит. Путаница с move после insert — типичная ошибка.

  • Entry API: map.entry(k).or_insert(v) — вставить, если ключа нет; возвращает &mut V. or_default() — значение по умолчанию. and_modify(...).or_insert(...).

Классический пример — подсчёт слов: взять запись и увеличить счётчик. or_insert не затирает уже существующее значение, в отличие от голого insert. or_default полезен для типов с Default (например, 0 для чисел). Без Entry часто делают лишний поиск ключа дважды.

  • Обход: for (k, v) in &map (порядок произвольный).

Итерация по ссылке не забирает карту. Порядок не стабилен и не отсортирован по ключу. Если нужен стабильный вывод (тесты, логи), скопируйте пары в Vec и отсортируйте. Опираться на «как напечаталось один раз» нельзя.

  • Хеш по умолчанию — SipHash (защита от DoS, умеренная скорость). Ключ должен реализовывать Eq + Hash.

SipHash устойчивее к атакам на хеш-таблицы через специально подобранные ключи. Для большинства программ этого достаточно. Тип ключа без Hash/Eq в HashMap не положить — компилятор потребует эти трейты. Свои типы часто помечают #[derive(Eq, PartialEq, Hash)].

  • Для упорядоченных ключей — BTreeMap.

Если нужен обход по возрастанию ключей или диапазоны, берите BTreeMap. HashMap быстрее в среднем для простого поиска, но без порядка. Выбор структуры данных зависит от того, важен ли порядок. Оба типа решают задачу «ключ → значение», но с разными свойствами.

Примеры

use std::collections::HashMap;

fn main() {
    let mut scores = HashMap::new();
    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);
    scores.insert(String::from("Blue"), 25); // перезапись

    scores.entry(String::from("Red")).or_insert(5);
    scores.entry(String::from("Blue")).or_insert(99); // не изменит 25

    let blue = scores.get("Blue").copied().unwrap_or(0);
    println!("{blue}");

    for (team, score) in &scores {
        println!("{team}: {score}");
    }
}
use std::collections::HashMap;

fn main() {
    let text = "hello world wonderful world";
    let mut counts: HashMap<&str, u32> = HashMap::new();
    for word in text.split_whitespace() {
        // Entry API: создать 0 или взять существующий счётчик
        *counts.entry(word).or_insert(0) += 1;
    }

    let mut pairs: Vec<_> = counts.iter().collect();
    pairs.sort(); // детерминированный вывод
    println!("{:?}", pairs);
}

Важно запомнить

Метод insert при том же ключе перезаписывает значение, а entry(...).or_insert(...) вставляет только если ключа ещё не было. Чтение через get даёт Option, а map[&k] паникует, если ключа нет. Порядок элементов в HashMap не гарантирован — для стабильного вывода отсортируйте пары отдельно. Владеющие значения вроде String при вставке перемещаются в карту.

Урок 18 · Часть I. Основы Rust

panic! и Result

Источник: ch09-01-unrecoverable-errors-with-panic.html

Кратко

В Rust ошибки делят на два больших класса. Невосстановимые — когда продолжать бессмысленно или обнаружен баг: тогда вызывают panic!, и поток (часто вся программа) завершается. Восстановимые — когда сбой ожидаем: нет файла, плохой ввод, сеть недоступна; их представляют типом Result<T, E>. Компилятор заставляет как-то обработать Result, а не проигнорировать тихой ошибкой. Такой подход делает обработку сбоев явной. Подробнее о Result: https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html

Основное

  • panic!("сообщение") — размотка стека (unwinding) и завершение. В Cargo.toml можно выбрать panic = 'abort' в [profile.release] — меньше бинарник, без размотки.

По умолчанию Rust разматывает стек: вызываются деструкторы, освобождаются ресурсы, затем поток останавливается. Режим abort сразу прерывает процесс без размотки — бинарник компактнее, но менее «мягкое» завершение. Для обучения достаточно понимать: паника — сигнал «здесь дальше нельзя». Это не замена обычным Result для ожидаемых сбоев.

  • Паника бывает от выхода за границы массива, unwrap() на None/Err, деления на ноль и т. д. RUST_BACKTRACE=1 включает трассировку.

Не только явный panic! приводит к панике — многие операции «взрываются» сами при нарушении контракта. Переменная окружения RUST_BACKTRACE=1 печатает стек вызовов и помогает найти место. Без трассировки видно только сообщение, и искать причину труднее. В отладочных сборках диагностика обычно информативнее.

  • Result<T, E>: enum Result<T, E> { Ok(T), Err(E) } — в prelude.

Ok несёт успешный результат типа T, Err — ошибку типа E. Имена доступны без импорта. Это тот же идея, что у Option, только вместо «нет значения» — «есть описание ошибки». Функции вроде открытия файла возвращают Result, а не паникуют при отсутствии файла.

  • Обработка через match по Ok/Err; по виду ошибки — error.kind() (io::ErrorKind::NotFound).

Полный match делает все ветки видимыми. Для io::Error можно различить «файл не найден» и «нет прав» и среагировать по-разному (создать файл или завершиться). Если все ошибки обрабатывать одинаково, код можно упростить методами. Главное — не проглотить Err молча без решения.

  • Сокращения:

- unwrap() — значение или panic;

- expect("msg") — panic с вашим сообщением;

- unwrap_or(v), unwrap_or_else(|e| ...), unwrap_or_default();

- map, map_err, ok(), is_ok().

unwrap/expect ускоряют прототип, но превращают ошибку в панику. expect лучше unwrap, потому что сообщение объясняет допущение («файл должен существовать в этом тесте»). unwrap_or* даёт запасной путь без паники. map меняет успех, map_err — вид ошибки; ok() переводит в Option.

  • unwrap/expect уместны в прототипах, тестах и когда ошибка невозможна по логике.

Если вы только что положили значение в Some/Ok и сразу достаёте — паника теоретически недостижима. В тестах паника часто даже желаема: тест должен упасть при неожиданном Err. В библиотечном коде для пользователей чаще возвращают Result. Слепое unwrap в проде — частый источник падений на реальных данных.

Примеры

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let f = File::open("hello.txt");
    let _file = match f {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("не удалось создать файл: {e:?}"),
            },
            other => panic!("не удалось открыть файл: {other:?}"),
        },
    };
}
use std::fs::File;

fn main() {
    // Короткий вариант с замыканием вместо вложенных match
    let _a = File::open("hello.txt").unwrap_or_else(|error| {
        panic!("ошибка: {error}");
    });
    // let _b = File::open("hello.txt").expect("hello.txt должен существовать");

    let n: i32 = "42".parse().unwrap_or(0);
    let bad: i32 = "abc".parse().unwrap_or(-1);
    println!("{n} {bad}");

    // let v = vec![1, 2, 3];
    // v[99]; // panic: index out of bounds
}

Важно запомнить

panic! подходит для багов и состояний, в которых продолжать работу нельзя. Result используют для ожидаемых сбоев: файлы, сеть, разбор ввода. Вызовы unwrap и expect превращают Err (или None) в панику. Если уж доставать значение насильно, expect с понятным сообщением обычно полезнее голого unwrap.

Урок 19 · Часть I. Основы Rust

Распространение ошибок

Источник: ch09-02-recoverable-errors-with-result.html#propagating-errors

Кратко

Не каждую ошибку нужно полностью разбирать в той же функции, где она возникла. Часто правильнее вернуть её вызывающему коду: «я не смог, вот причина». Для этого функция возвращает Result, а оператор ? коротко говорит: при успехе возьми значение, при ошибке сразу верни её из функции. Так цепочки операций с файлами и сетью остаются читаемыми, без лестницы из match. Отдельно решают, где уместна паника, а где — именно Result. Об этом: https://doc.rust-lang.org/book/ch09-03-to-panic-or-not-to-panic.html

Основное

  • Функция возвращает Result<T, E>; ошибка уходит наверх.

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

  • Оператор ? после выражения Result: при Ok(v) даёт v, при Err(e) немедленно возвращает Err(From::from(e)) из функции.

Вместо длинного match пишете let mut f = File::open("a.txt")?;. При ошибке остальной код функции не выполняется. From::from может преобразовать тип ошибки к тому, что функция объявила. Без совместимого возвращаемого типа ? не скомпилируется — это защита от «тихого» неверного использования.

  • ? применим только в функциях, чей возвращаемый тип совместим: Result, Option (для Option — возвращает None). Нельзя смешивать Result и Option без ok_or/ok().

В обычной функции, которая возвращает «голый» тип без Result/Option, поставить ? нельзя. Для Option оператор ? ранним выходом отдаёт None. Смешение File::open(path)? внутри функции, возвращающей Option, потребует явного моста между типами. Ошибка компилятора здесь обычно прямолинейная: неверный тип возврата.

  • Преобразование ошибок: ? вызывает From::from, поэтому разные типы ошибок приводятся к общему (свой enum с impl From<...> или Box<dyn Error>).

Одна функция может читать файл (io::Error) и парсить число (ParseIntError). Общий enum ошибок + From позволяет писать ? для обоих. Box<dyn Error> удобен в приложениях и в main, когда детали типа ошибки менее важны. Без преобразования пришлось бы вручную оборачивать каждую ошибку.

  • main может возвращать Result<(), E>, например Result<(), Box<dyn Error>>; при Err процесс завершится с кодом ≠ 0.

Тогда в main тоже можно писать ?. Успех — Ok(()) в конце. Это убирает лишние unwrap в простых программах. Операционная система получает ненулевой код выхода при ошибке — удобно для скриптов и CI. Не забудьте тип возврата у fn main().

  • Цепочки: File::open(p)?.read_to_string(&mut s)?. Готовое: fs::read_to_string(path).

Несколько шагов с ? читаются сверху вниз как «сделай, сделай, верни Ok». Для чтения файла в строку стандартная библиотека уже даёт короткую функцию. Дублировать низкоуровневый код не обязательно. Цепочки особенно полезны в коде с I/O.

  • Когда паниковать: нарушены инварианты/контракты, невалидное состояние, примеры, тесты. Когда Result: сбой возможен и ожидаем (ввод, сеть, файл).

Паника — для «этого не должно было случиться». Result — для «это может случиться у пользователя». Путать их вредно: либо программа падает на нормальном вводе, либо баги маскируются под «ожидаемые ошибки». В учебных примерах паника допустима ради краткости.

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

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

  • Крейты thiserror (библиотеки) и anyhow (приложения) упрощают работу, но не нужны для основ.

thiserror помогает аккуратно описать типы ошибок библиотеки. anyhow удобен, когда приложению достаточно «какой-то ошибки» с контекстом. В учебнике и первых проектах достаточно стандартного Result и своего enum. Знайте имена — пригодятся позже.

Примеры

use std::fs::{self, File};
use std::io::{self, Read};

fn read_username() -> Result<String, io::Error> {
    let mut f = File::open("username.txt")?; // ошибка сразу уйдёт наверх
    let mut s = String::new();
    f.read_to_string(&mut s)?;
    Ok(s)
}

fn read_short() -> Result<String, io::Error> {
    fs::read_to_string("username.txt")
}

fn main() {
    match read_username() {
        Ok(name) => println!("{name}"),
        Err(e) => println!("ошибка: {e}"),
    }
    let _ = read_short();
}
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;

#[derive(Debug)]
enum AppError {
    Parse(ParseIntError),
    Negative(i32),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::Parse(e) => write!(f, "не число: {e}"),
            AppError::Negative(n) => write!(f, "отрицательное: {n}"),
        }
    }
}

impl Error for AppError {}

impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> Self {
        AppError::Parse(e)
    }
}

fn parse_positive(s: &str) -> Result<i32, AppError> {
    let n: i32 = s.trim().parse()?; // ParseIntError -> AppError через From
    if n < 0 {
        return Err(AppError::Negative(n));
    }
    Ok(n)
}

fn first_char(s: &str) -> Option<char> {
    s.chars().next() // для Option тоже бывает уместен `?`
}

fn main() -> Result<(), Box<dyn Error>> {
    println!("{}", parse_positive("42")?);
    println!("{:?}", first_char("abc"));
    println!("{}", parse_positive("-5").unwrap_err());
    Ok(())
}

Важно запомнить

Оператор ? разворачивает Ok в значение или немедленно возвращает Err из текущей функции. При возврате ошибка может быть преобразована через трейт From. Функция main тоже может возвращать Result, и тогда в ней допустим ?. Панику оставляют для багов и нарушенных инвариантов; для ожидаемых сбоев используют и пробрасывают Result.

Практика 3 · Часть I. Основы Rust

модули, коллекции, ошибки

Задания для закрепления темы · решений нет

Темы: пакеты и модули, Vec, String, HashMap, panic!, Result, оператор ? (уроки 03-01 … 03-05).

Задача 1. Модуль геометрии

Сложность: лёгкое

В src/main.rs создайте модуль geometry с вложенным pub mod circle. В нём:

  • pub fn area(r: f64) -> f64;
  • приватную fn square(x: f64) -> f64, которую использует area.

Вызовите geometry::circle::area(2.0) из main, затем импортируйте функцию через use и вызовите короче. Попробуйте вызвать square из main и запишите в комментарии текст ошибки.

Задача 2. Статистика вектора

Сложность: лёгкое

Дан let v = vec![5, 3, 9, 1, 7, 3];. Выведите: сумму, среднее (f64), минимум, максимум и отсортированную копию (clone() + sort()). Затем безопасно получите элемент с индексом 10 через v.get(10) и выведите нет элемента, если его нет.

Подсказка: iter().sum::<i32>(), iter().min() возвращает Option<&i32>.

Задача 3. Подсчёт слов

Сложность: среднее

Напишите fn word_count(text: &str) -> HashMap<String, usize>: слова приводятся к нижнему регистру, знаки препинания по краям убираются. Выведите слова по убыванию частоты (при равенстве — по алфавиту).

Пример: "Я иду. Я иду домой, я!" → я: 3, иду: 2, домой: 1.

Подсказка: split_whitespace(), trim_matches(|c: char| !c.is_alphanumeric()), to_lowercase(), entry().or_insert(0), затем Vec<_> и sort_by.

Задача 4. Безопасный парсинг

Сложность: среднее

Напишите fn parse_age(s: &str) -> Result<u8, String>:

  • нечисловая строка → Err("не число: ...");
  • значение больше 150 → Err("слишком большой возраст");
  • иначе Ok(age).

Проверьте на "25", "abc", "200", "-5" и выведите результаты через match.

Подсказка: s.trim().parse::<u8>().map_err(|e| format!("не число: {e}"))?.

Задача 5. Чтение конфигурации

Сложность: сложное

Создайте файл config.txt со строками вида ключ=значение. Напишите:

  • enum ConfigError { Io(std::io::Error), BadLine(usize), BadNumber(String) };
  • impl Display и impl From<std::io::Error>;
  • fn load(path: &str) -> Result<HashMap<String, String>, ConfigError>: читает файл, пропускает пустые строки и строки с #, при строке без = возвращает BadLine(номер);
  • fn get_port(cfg: &HashMap<String, String>) -> Result<u16, ConfigError>: ключ port → u16, иначе BadNumber.

main возвращает Result<(), Box<dyn std::error::Error>> и использует ?. Проверьте три случая: корректный файл, отсутствующий файл, строка без =.

Подсказка: fs::read_to_string(path)?, lines().enumerate(), split_once('=').

Задача 6. Мини-библиотека с модулями

Сложность: сложное

Создайте пакет cargo new library --lib и бинарник src/main.rs. В src/lib.rs:

  • pub mod book (Book { title, author, year }, конструктор new);
  • pub mod catalog (Catalog с HashMap<String, Vec<Book>> по автору; методы add, by_author(&self, &str) -> &[Book], titles(&self) -> Vec<String>; remove(&mut self, title) -> Result<Book, CatalogError>);
  • pub use для Book и Catalog в корне крейта.

CatalogError::NotFound(String) реализуйте с Display. В main.rs добавьте 4–5 книг (минимум у двух авторов), выведите книги автора, удалите существующую и несуществующую книгу, обработав ошибку через match.

Подсказка: entry(author).or_default().push(book), unwrap_or(&[]) для by_author; используйте library::{Book, Catalog} в main.rs. Авторы с пустым списком книг после удаления можно оставить.

Часть II. Углублённый Rust

Глава 1

Обобщения, трейты, тесты

Урок 1 · Часть II. Углублённый Rust

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

Источник: ch10-01-syntax.html

Кратко

Обобщения (generics) позволяют писать один и тот же код для разных типов, не копируя функции вручную. Вы помечаете параметр типа вроде T, и компилятор подставляет конкретные типы при сборке. Это похоже на шаблон «работает с любым подходящим типом», но с проверкой на этапе компиляции. Благодаря мономорфизации обобщённый код обычно работает так же быстро, как будто вы написали отдельные версии для i32, char и так далее. Платите в основном размером бинарника, а не скоростью в рантайме.

Основное

  • Обобщённые функции. Параметры типа указываются в угловых скобках после имени функции. Если над значениями нужны операции (сравнение, сложение), параметр ограничивается трейтом (T: PartialOrd).

Сигнатура fn largest<T: PartialOrd>(list: &[T]) -> &T читается так: «для любого T, который умеет сравниваться». Без ограничения над T почти ничего нельзя сделать: компилятор не знает, есть ли у типа > или другие операции. Если ограничение забыть, получите ошибку про отсутствующий трейт. С ограничением одна функция обслуживает и числа, и символы.

  • Обобщённые структуры и перечисления. Структура может иметь несколько параметров типа: Point<T, U>. Стандартные Option<T> и Result<T, E> — тоже обобщённые перечисления.

Поля могут быть разных параметров: x типа T, y типа U. Так точка может хранить i32 и f64 одновременно. Option и Result — лучшие примеры из std: один механизм, много типов содержимого. Без generics пришлось бы плодить OptionI32, OptionString и десятки копий.

  • Обобщённые методы. Параметр после impl объявляет его: impl<T> Point<T>. Можно реализовать методы только для конкретного типа: impl Point<f32> — тогда они доступны лишь для Point<f32>. Параметры метода могут отличаться от параметров структуры (например, mixup<X2, Y2>).

Запись impl<T> Point<T> говорит: методы есть для любого T. Специализация impl Point<f32, f32> добавляет методы вроде расстояния до начала координат, где нужны операции f32. Метод mixup показывает: у метода могут быть свои параметры типа, не только у структуры. Это гибко, но сигнатуры нужно читать внимательно.

  • Мономорфизация. Для Option<i32> и Option<f64> компилятор генерирует отдельные версии кода. Платой за это становится только увеличение размера бинарного файла, но не скорости работы.

В рантайме нет «поиска реализации по типу» для обычных generics — код уже конкретный. Поэтому обобщения в Rust называют zero-cost abstraction в типичном случае. Минус — бинарник может стать больше, если очень много разных подстановок. Для большинства программ это приемлемый обмен.

Примеры

// Нужен PartialOrd, иначе нельзя сравнивать item > largest
fn largest<T: PartialOrd>(list: &[T]) -> &T {
    let mut largest = &list[0];
    for item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}

struct Point<T, U> {
    x: T,
    y: U,
}

impl<T, U> Point<T, U> {
    fn x(&self) -> &T {
        &self.x
    }

    // Свои параметры типа у метода
    fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<T, Y2> {
        Point { x: self.x, y: other.y }
    }
}

// Эти методы есть только у Point<f32, f32>
impl Point<f32, f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

enum Maybe<T> {
    Just(T),
    Nothing,
}

fn main() {
    println!("{}", largest(&[34, 50, 25, 100, 65]));
    println!("{}", largest(&['y', 'm', 'a', 'q']));

    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c' };
    let p3 = p1.mixup(p2);
    println!("x = {}, y = {}", p3.x, p3.y);

    let f = Point { x: 3.0_f32, y: 4.0_f32 };
    println!("{}", f.distance_from_origin()); // 5

    let m: Maybe<i32> = Maybe::Just(1);
    if let Maybe::Just(v) = m {
        println!("{v}");
    }
}

Важно запомнить

Параметры типа записывают в угловых скобках: у функций (fn f<T>), структур (struct S<T>) и блоков impl (impl<T> S<T>). Без ограничений трейтами над значением типа T почти нельзя выполнить осмысленные операции. Можно делать специализированные impl только для конкретных подстановок, например для Point<f32, f32>. Мономорфизация даёт скорость конкретного кода ценой возможного роста размера бинарника. Многие типы std — обобщённые: Option<T>, Result<T, E>, Vec<T>, HashMap<K, V>.

Урок 2 · Часть II. Углублённый Rust

Трейты: описание общего поведения

Источник: ch10-02-traits.html

Кратко

Трейт описывает набор методов, которые тип обещает предоставить — похоже на интерфейс в других языках. С помощью трейтов пишут обобщённый код вида «подходит любой тип, который умеет X». Можно реализовать трейт для своих типов и тем самым добавить им общее поведение. Методы трейта бывают обязательными и с реализацией по умолчанию. Ограничения (T: Summary) говорят компилятору, какие операции над обобщённым параметром разрешены. Так абстракции остаются явными и проверяемыми при компиляции.

Основное

  • Объявление и реализация. trait Summary { fn summarize(&self) -> String; } описывает сигнатуры. Реализация выполняется блоком impl Summary for NewsArticle { ... }.

В трейте вы фиксируете «что нужно уметь», в impl — «как именно этот тип это делает». Разные типы могут реализовать один трейт по-своему. Без реализации вызов метода трейта на типе не скомпилируется. Это основа полиморфизма в Rust на этапе компиляции (через ограничения и мономорфизацию).

  • Правило сироты (orphan rule). Реализовать трейт для типа можно, только если либо трейт, либо тип определён в вашем крейте. Нельзя реализовать Display для Vec<T> в своём крейте — это защищает от конфликтующих реализаций.

Если бы все могли реализовывать чужие трейты для чужих типов, при сборке могли бы встретиться две разные реализации одного и того же. Правило сироты это предотвращает. На практике: для своего типа реализуете чужой трейт, или для чужого типа — свой трейт (часто через newtype-обёртку). Ошибка компилятора про orphan rules как раз об этом.

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

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

  • Трейты как параметры.

- fn notify(item: &impl Summary) — краткая запись.

- fn notify<T: Summary>(item: &T) — полная запись (trait bound); нужна, когда надо связать типы нескольких параметров.

- Несколько ограничений: T: Summary + Display.

- where-условия делают сигнатуры читаемее.

Синтаксис impl Trait в аргументе — сахар для обобщения с ограничением. Когда два параметра должны быть одним и тем же типом T, нужна явная форма с <T: ...>. Плюс + объединяет требования. Блок where выносит длинные ограничения из середины сигнатуры и улучшает читаемость.

  • Трейты в возвращаемом значении. fn make() -> impl Summary скрывает конкретный тип, но функция должна возвращать один конкретный тип во всех ветках. Для разных типов нужны трейт-объекты (Box<dyn Summary>, см. главу про ООП).

Вызывающему достаточно знать: «вернётся что-то с Summary». Компилятор всё равно фиксирует один скрытый тип. Нельзя в одной ветке вернуть Post, а в другой Article под видом impl Summary — типы разные. Когда нужны разные типы в рантайме, переходят к dyn Trait.

  • Условная реализация. impl<T: Display> ToString for T — blanket implementation: трейт реализуется для всех типов, удовлетворяющих ограничению. Условные методы: impl<T: Display + PartialOrd> Pair<T> { ... }.

Blanket implementations широко используются в стандартной библиотеке: реализовали Display — бесплатно получили to_string(). Условные методы появляются у типа только когда параметр удовлетворяет трейтам. Если вызвать такой метод у Pair без нужных трейтов, компилятор укажет на отсутствующие ограничения. Это позволяет наращивать API по мере возможностей T.

Примеры

use std::fmt::Display;

trait Summary {
    fn author(&self) -> String;

    // По умолчанию опирается на обязательный author
    fn summarize(&self) -> String {
        format!("(Читать далее от {}...)", self.author())
    }
}

struct Post {
    user: String,
    text: String,
}

struct Article {
    title: String,
    writer: String,
}

impl Summary for Post {
    fn author(&self) -> String {
        format!("@{}", self.user)
    }
}

impl Summary for Article {
    fn author(&self) -> String {
        self.writer.clone()
    }

    fn summarize(&self) -> String {
        format!("{}, автор {}", self.title, self.writer)
    }
}

fn notify(item: &impl Summary) {
    println!("Срочно! {}", item.summarize());
}

fn show<T, U>(t: &T, u: &U) -> String
where
    T: Summary + Display,
    U: Summary,
{
    format!("{t}: {}", u.summarize())
}

fn make_post() -> impl Summary {
    Post { user: "rust".into(), text: "Привет".into() }
}

struct Pair<T> {
    x: T,
    y: T,
}

// Метод доступен только при нужных трейтах у T
impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("Наибольший x = {}", self.x);
        } else {
            println!("Наибольший y = {}", self.y);
        }
    }
}

fn main() {
    let post = Post { user: "ilya".into(), text: "Rust!".into() };
    notify(&post);
    notify(&Article { title: "Трейты".into(), writer: "Автор".into() });
    notify(&make_post());
    Pair { x: 3, y: 7 }.cmp_display();
}

Важно запомнить

Трейт задаёт поведение, а реализация пишется как impl Trait for Type. По правилу сироты хотя бы трейт или тип должны быть локальными для вашего крейта. Методы с телом по умолчанию можно переопределить, но вызвать «версию по умолчанию» из переопределения нельзя. Запись impl Trait в аргументах — удобный сахар для T: Trait; длинные ограничения лучше выносить в where. Возврат -> impl Trait скрывает один конкретный тип; разные типы в разных ветках требуют иного подхода, например Box<dyn Trait>. Blanket implementations вроде impl<T: Display> ToString for T активно используются в стандартной библиотеке.

Урок 3 · Часть II. Углублённый Rust

Валидация ссылок с помощью времён жизни

Источник: ch10-03-lifetime-syntax.html

Кратко

У каждой ссылки в Rust есть время жизни — область программы, в которой ссылка гарантированно валидна. Обычно компилятор выводит его сам. Когда связь между входными и выходными ссылками неоднозначна, её описывают аннотациями вроде 'a. Аннотации не удлиняют жизнь данных в памяти: они только объясняют borrow checker, как соотносятся сроки. Благодаря этому висячие ссылки отсекаются при компиляции, а не проявляются загадочным падением позже. Чаще всего явные времена жизни встречаются у функций, возвращающих ссылки, и у структур, которые хранят ссылки.

Основное

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

Если бы ссылка осталась «живой» после освобождения данных, чтение могло бы дать мусор или падение. Аналогия: нельзя держать адрес квартиры после того, как дом снесли. Компилятор сверяет области { ... } и потоки заимствований. Ошибка lifetime выглядит строго, но она указывает на реальную логическую проблему в коде.

  • Аннотации в функциях. fn longest<'a>(x: &'a str, y: &'a str) -> &'a str означает: результат живёт не дольше, чем меньшая из областей жизни x и y. Если результат зависит только от одного аргумента, достаточно аннотировать его.

Общая 'a связывает входы и выход: вызывающий не сможет использовать результат дольше, чем живёт более короткий аргумент. Это не «магия продления», а ограничение на использование результата. Если вернуть только x, аннотировать y тем же 'a не обязательно. Неверные аннотации либо не скомпилируются, либо (реже) окажутся слишком жёсткими для вызывающего кода.

  • Аннотации в структурах. Структура, хранящая ссылку, обязана указать время жизни: struct Excerpt<'a> { part: &'a str }. Экземпляр не может пережить данные, на которые ссылается.

Без 'a компилятор не знает, как долго поле-ссылка остаётся валидным. Экземпляр Excerpt «привязан» к заимствованному тексту. Если исходная String уничтожится раньше, использовать структуру будет нельзя — и это проверяется статически. Любая структура со ссылками почти всегда несёт параметр времени жизни.

  • Правила пропуска (elision). Компилятор подставляет времена жизни сам, если выполняются правила:

1. Каждому входному ссылочному параметру — свой параметр времени жизни.

2. Если входной параметр ровно один, его время жизни присваивается всем выходным.

3. Если среди входных есть &self или &mut self, время жизни self присваивается всем выходным.

Если после применения правил выход остаётся неопределённым — это ошибка компиляции.

Поэтому у многих методов явных 'a нет: правило для &self покрывает типичный случай. Функция с двумя входными срезами и выходным срезом часто требует явной аннотации — правила не угадывают связь. Elision не ослабляет проверки, а лишь сокращает запись. Когда компилятор просит аннотации — значит, без вашей подсказки связь неоднозначна.

  • Время жизни 'static. Ссылка живёт всю программу. Строковые литералы имеют тип &'static str. Использовать 'static для «починки» ошибок стоит осторожно: чаще всего проблема в другом.

Литералы вшиты в бинарник, поэтому на них можно ссылаться всегда. Требование 'static в API иногда означает «данные должны жить вечно» (или владение должно быть передано иначе). Подставить 'static наугад, лишь бы замолчало, — плохая привычка: она маскирует неверную модель владения. Сначала подумайте, кто должен владеть данными.

  • Всё вместе. Параметры времени жизни и типа указываются в одном списке: fn f<'a, T: Display>(x: &'a str, ann: T) -> &'a str.

Сначала обычно идут lifetime-параметры, затем типы и их ограничения (или вынос в where). Так в одной сигнатуре сочетаются generics и ссылки. Читать такую запись нужно спокойно: 'a про срок ссылки, T: Display — про возможности значения. По мере практики такие сигнатуры становятся привычными.

Примеры

use std::fmt::Display;

// Результат не живёт дольше более короткого из x и y
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

// Связь только с первым аргументом
fn first<'a>(x: &'a str, _y: &str) -> &'a str {
    x
}

struct Excerpt<'a> {
    part: &'a str,
}

impl<'a> Excerpt<'a> {
    // Elision: выход связан с &self
    fn announce(&self, announcement: &str) -> &str {
        println!("Внимание: {announcement}");
        self.part
    }
}

fn longest_with_note<'a, T>(x: &'a str, y: &'a str, ann: T) -> &'a str
where
    T: Display,
{
    println!("Объявление: {ann}");
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let s1 = String::from("длинная строка");
    let result;
    {
        let s2 = String::from("xyz");
        result = longest(s1.as_str(), s2.as_str());
        println!("{result}"); // внутри области s2 — можно
    }
    // println!("{result}"); // ошибка: s2 уже не живёт

    let novel = String::from("Зови меня Измаил. Несколько лет назад...");
    let sentence = novel.split('.').next().unwrap();
    let ex = Excerpt { part: sentence };
    println!("{}", ex.announce("Читаем"));

    let s: &'static str = "живу всю программу";
    println!("{} {}", first(s, "y"), longest_with_note("a", "bb", 42));
}

Важно запомнить

Времена жизни нужны, чтобы не допускать висячие ссылки; проверка идёт при компиляции и не создаёт отдельной «службы времени жизни» в рантайме. Аннотация 'a описывает связь между ссылками, но не продлевает жизнь самих данных. Результат с общей аннотацией нельзя использовать дольше самого короткого из связанных входных заимствований. Структуры со ссылками объявляют параметр <'a>, а в реализации пишут impl<'a> S<'a>. Три правила elision покрывают большинство обычных функций и методов, поэтому явные аннотации нужны не так часто. Вернуть ссылку на локальную переменную нельзя — возвращайте владеющее значение, например String.

Урок 4 · Часть II. Углублённый Rust

Написание автоматических тестов

Источник: ch11-01-writing-tests.html

Кратко

Тесты в Rust — это обычные функции, помеченные атрибутом #[test]. Их запускает команда cargo test: она собирает отдельный тестовый бинарник, запускает тесты (по умолчанию параллельно) и показывает сводку. Тест считается проваленным, если функция паникует — это как «красные флажки» на конвейере: если хоть один упал, сборка тестов сообщает о проблеме. Макросы вроде assert! и assert_eq! сами вызывают панику при неверном результате, поэтому вам не нужно писать панику вручную для обычных проверок. Ниже также разобраны параметры запуска из главы 11.2: фильтры по имени, потоки и #[ignore].

Основное

Анатомия теста

cargo new adder --lib создаёт библиотеку и уже кладёт в неё модуль tests под атрибутом #[cfg(test)]. Это удобный шаблон: тестовый код живёт рядом с библиотекой, но не попадает в обычную сборку.

Каждая функция с #[test] — отдельный тест. Cargo находит все такие функции и вызывает их по очереди (или параллельно). Если функция доходит до конца без паники, тест зелёный; если паникует — красный. Думайте об атрибуте #[test] как о ярлыке «это проверялка», который Cargo ищет при cargo test.

Макросы проверок

  • assert!(выражение) — проходит, если выражение истинно (true). Если ложно, макрос паникует и тест падает. Это самая простая проверка: «убедись, что условие верно». Без такой проверки легко пропустить логическую ошибку, которая «молча» даёт неверный результат.
  • assert_eq!(left, right) и assert_ne!(left, right) сравнивают два значения. При несовпадении (или совпадении для assert_ne!) тест падает и печатает оба значения — это сильно упрощает отладку. Значения должны реализовывать PartialEq (чтобы их можно было сравнить) и Debug (чтобы показать их в сообщении об ошибке). Порядок аргументов не важен: assert_eq!(a, b) и assert_eq!(b, a) одинаково корректны.
  • Своё сообщение: assert!(cond, "Ответ был {}", value). Когда проверка падает, вы видите не только «assert failed», но и свой текст с деталями. Это как подпись к фото ошибки: сразу понятно, что именно пошло не так и какое значение получилось.

Проверка паники

#[should_panic] говорит: «тест проходит, только если код внутри паникует». Это нужно для проверок вроде «некорректный ввод должен остановить программу». Если код тихо продолжит работу без паники, тест упадёт — значит, защита не сработала.

#[should_panic(expected = "часть сообщения")] дополнительно проверяет текст паники. Так вы не примете за успех «чужую» панику (например, из другой ошибки), а убедитесь, что сработало именно ожидаемое сообщение. Это точнее и безопаснее голого #[should_panic].

Result в тестах

Тест может возвращать Result<(), E>: Ok(()) — успех, Err(...) — провал. Тогда внутри удобно писать код с оператором ?, как в обычных функциях: ошибка сама «всплывёт» и провалит тест. Это особенно полезно, когда проверяете операции, которые и так возвращают Result (чтение файла, разбор строки и т. п.).

Нельзя совмещать возврат Result с #[should_panic]: эти два стиля проверки конфликтуют. Выберите один способ: либо паника/assert!, либо Result.

Управление запуском

cargo test [опции] -- [опции тестового бинарника]: всё, что после --, уходит уже запущенным тестам, а не Cargo.

  • -- --test-threads=1 — последовательный запуск в одном потоке. По умолчанию тесты идут параллельно, как несколько рабочих на одной кухне. Если они делят файл или переменную окружения, возможны гонки и «мигающие» падения. Один поток убирает эту гонку ценой скорости.
  • -- --show-output — показывать вывод (println! и т. п.) и у успешных тестов. По умолчанию вывод успешных тестов скрыт, чтобы не засорять экран; у проваленных он уже виден.
  • cargo test имя — фильтр по подстроке имени. cargo test add запустит все тесты, в имени которых есть add. Это удобно, когда чините одну область и не хотите ждать весь набор.
  • #[ignore] — пометить тяжёлый или редкий тест, чтобы он не запускался каждый раз. -- --ignored запускает только такие тесты, -- --include-ignored — и обычные, и игнорируемые. Так вы разделяете «быстрые проверки на каждый коммит» и «долгие проверки по необходимости».

Примеры

pub struct Rectangle {
    pub width: u32,
    pub height: u32,
}

impl Rectangle {
    pub fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

pub fn add_two(a: u64) -> u64 {
    a + 2
}

pub fn greeting(name: &str) -> String {
    format!("Привет, {name}!")
}

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        // Паника при неверном вводе — это ожидаемое поведение для теста #[should_panic]
        if !(1..=100).contains(&value) {
            panic!("Значение должно быть от 1 до 100, получено {value}.");
        }
        Guess { value }
    }
}

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

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle { width: 8, height: 7 };
        let smaller = Rectangle { width: 5, height: 1 };
        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn adds_two() {
        // При падении увидим оба значения: ожидаемое и фактическое
        assert_eq!(4, add_two(2));
    }

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Илья");
        assert!(
            result.contains("Илья"),
            "В приветствии нет имени, получено `{result}`"
        );
    }

    #[test]
    #[should_panic(expected = "от 1 до 100")]
    fn greater_than_100() {
        Guess::new(200);
    }

    #[test]
    fn it_works() -> Result<(), String> {
        if 2 + 2 == 4 {
            Ok(())
        } else {
            Err(String::from("два плюс два не равно четырём"))
        }
    }

    #[test]
    #[ignore]
    fn expensive_test() {
        // Долгий тест: не запускается без --ignored
    }
}

Важно запомнить

Тест — это функция с атрибутом #[test]; провал означает, что функция запаниковала (напрямую или через assert!).

assert_eq! требует PartialEq и Debug и при ошибке показывает оба значения — это упрощает поиск бага.

#[should_panic(expected = "...")] делает проверку паники точнее: вы проверяете и факт паники, и её сообщение.

Тесты, возвращающие Result, позволяют использовать ? и естественно проверять код с ошибками.

По умолчанию тесты идут параллельно; общие ресурсы (файлы, переменные окружения) могут вызвать гонки — используйте --test-threads=1 или разделяйте ресурсы.

#[ignore] и фильтры по имени помогают запускать только нужные тесты и не тратить время на долгие проверки каждый раз.

Урок 5 · Часть II. Углублённый Rust

Организация тестов

Источник: ch11-03-test-organization.html

Кратко

Тесты в Rust делятся на модульные (unit) и интеграционные. Модульные лежат рядом с кодом в src/ и могут заглядывать «внутрь»: проверять даже приватные функции. Интеграционные живут в каталоге tests/ и видят библиотеку только снаружи — как внешний пользователь, через публичный API. Это похоже на проверку двигателя на стенде (unit) и на проверку всей машины на дороге (integration). Doc-тесты из документации тоже запускаются при cargo test и помогают не дать примерам устареть.

Основное

Модульные тесты

Размещаются в файлах src/ в модуле tests с атрибутом #[cfg(test)]. Этот код компилируется только при cargo test, поэтому не раздувает обычную сборку и не попадает в релизный бинарник. Вы платите за тесты только когда их запускаете.

Благодаря use super::*; доступны и приватные функции — Rust допускает тестирование внутренних деталей. Это важно: вы можете проверить вспомогательную логику, которую пользователь библиотеки вообще не видит. Если бы тестировать можно было только pub-функции, многие баги внутри модуля остались бы незамеченными до интеграции.

Интеграционные тесты

  • Каталог tests/ рядом с src/. Cargo автоматически подхватывает файлы оттуда как отдельные тестовые крейты. Это проверка «с точки зрения клиента»: импортируете библиотеку и вызываете только то, что объявлено публичным.
  • Каждый файл в tests/ компилируется как отдельный крейт; нужно подключать библиотеку: use adder::add_two;. Имена модулей внутри src/ сами собой не видны — только публичный корень крейта. Если интеграционный тест не компилируется, часто причина в том, что функция не pub или не реэкспортирована.
  • #[cfg(test)] здесь не нужен: весь каталог tests/ и так существует только для тестирования. Добавлять этот атрибут избыточно и обычно не делают.
  • Запуск одного файла: cargo test --test integration_test (имя без .rs). Так быстрее итерация, когда чините один сценарий, а не весь набор.
  • Cargo выводит секции результатов: модульные, интеграционные, doc-тесты. Если один этап падает, следующие по умолчанию не запускаются — сначала чинят упавшее. Флаг --no-fail-fast меняет это и гоняет всё до конца, что удобно в CI, чтобы увидеть все ошибки сразу.

Общие вспомогательные модули

Файл tests/common.rs Cargo воспринял бы как ещё один тестовый крейт и попытался бы «запустить» его как набор тестов. Чтобы этого избежать, создают tests/common/mod.rs — каталог с mod.rs не считается отдельным тестовым файлом верхнего уровня.

Подключают его через mod common; в каждом интеграционном файле, которому нужна общая подготовка. Так вы не копируете setup-код и не создаёте случайный «пустой» тестовый крейт без тестов.

Бинарные крейты

Интеграционные тесты не могут импортировать функции из src/main.rs: бинарный крейт не даёт библиотечного API для use. Поэтому логику выносят в src/lib.rs, а main.rs оставляют тонкой обёрткой, которая только вызывает библиотеку.

Если вся логика сидит в main.rs, вы почти лишены интеграционных тестов «снаружи» и вынуждены полагаться только на модульные тесты внутри того же файла. Разделение main / lib — стандартный приём Книги для тестируемых CLI-программ.

Doc-тесты

Блоки кода в ///-комментариях выполняются как тесты при cargo test. Документация остаётся «живой»: если API изменился, а пример в комментарии — нет, тест упадёт. Это защита от устаревших сниппетов, которые вводят читателя в заблуждение.

Примеры

// src/lib.rs
pub fn add_two(a: u64) -> u64 {
    internal_adder(a, 2)
}

fn internal_adder(left: u64, right: u64) -> u64 {
    left + right
}

/// Утраивает число.
///
/// ```
/// assert_eq!(adder::triple(3), 9);
/// ```
pub fn triple(x: u64) -> u64 {
    x * 3
}

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

    // Приватную функцию тестировать можно — только в unit-тестах
    #[test]
    fn internal() {
        assert_eq!(4, internal_adder(2, 2));
    }
}
// tests/common/mod.rs — общий код, не отдельный тестовый крейт
pub fn setup() {
    // общая подготовка для тестов
}
// tests/integration_test.rs
use adder::add_two;

mod common;

#[test]
fn it_adds_two() {
    common::setup();
    assert_eq!(6, add_two(4));
}

Важно запомнить

Модульные тесты пишут в #[cfg(test)] mod tests рядом с кодом и могут проверять приватные функции.

Интеграционные тесты лежат в tests/: каждый файл — отдельный крейт, доступен только публичный API.

Общий код для интеграционных тестов кладите в tests/common/mod.rs, а не в tests/common.rs, иначе Cargo сочтёт файл тестовым крейтом.

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

cargo test --test имя_файла запускает конкретный интеграционный файл; doc-тесты из /// тоже выполняются автоматически при cargo test.

Практика 1 · Часть II. Углублённый Rust

Практика: обобщения, трейты, времена жизни, тесты

Задания для закрепления темы · решений нет

Темы: 04-01–04-05. Каждое задание проверяйте тестами (cargo test).

Задание 1. Наименьший элемент

Сложность: лёгкое

Напишите fn smallest<T: PartialOrd>(list: &[T]) -> Option<&T>, возвращающую ссылку на наименьший элемент или None для пустого среза. Добавьте тесты для i32, char, &str и пустого среза.

Подсказка: list.iter() и сравнение item < current.

Задание 2. Трейт Shape

Сложность: лёгкое

Определите трейт Shape с методом area(&self) -> f64 и методом по умолчанию describe(&self) -> String (например, "Фигура площадью 12.57"). Реализуйте его для Circle и Rectangle. Напишите функцию print_area(shape: &impl Shape). Проверьте area тестами с assert!((a - b).abs() < 1e-9).

Задание 3. Обобщённый стек

Сложность: среднее

Реализуйте struct Stack<T> с методами new, push, pop, peek(&self) -> Option<&T>, len, is_empty. Добавьте метод fn max(&self) -> Option<&T> только для T: PartialOrd. Покройте модульными тестами, включая #[should_panic] для метода pop_or_panic, паникующего на пустом стеке с сообщением «стек пуст».

Задание 4. Время жизни в парсере

Сложность: среднее

Напишите struct Words<'a> { text: &'a str } с методом longest(&self) -> Option<&'a str>, возвращающим самое длинное слово (по split_whitespace). Затем напишите отдельную функцию fn first_word_or<'a>(text: &'a str, default: &'a str) -> &'a str. Объясните в комментарии, что произойдёт, если убрать аннотацию из first_word_or, и почему компилятор отвергнет такой код. Добавьте интеграционный тест в tests/words.rs (код — в lib.rs).

Задание 5. Blanket-реализация и newtype

Сложность: сложное

Определите трейт Describe { fn describe(&self) -> String; } и реализуйте его сразу для всех T: std::fmt::Debug (blanket impl): результат — format!("<<{:?}>>", self). Затем создайте newtype Celsius(f64) с #[derive(Debug)], реализуйте для него Display ("21.5°C") и убедитесь, что Celsius получает Describe автоматически. Напишите fn describe_all<T: Describe>(items: &[T]) -> Vec<String> и проверьте тестами на i32, &str и Celsius.

Подсказка: подумайте, почему нельзя дополнительно реализовать Describe для i32 вручную (конфликт реализаций).

Задание 6. Мини-библиотека валидации с тестами

Сложность: сложное

Создайте библиотечный крейт validator:

  • трейт Rule<T> { fn check(&self, value: &T) -> Result<(), String>; };
  • структуры MinLen(usize) для String и Range { min: i32, max: i32 } для i32;
  • fn validate_all<'a, T>(value: &T, rules: &'a [Box<dyn Rule<T>>]) -> Vec<String> — все ошибки;
  • fn first_error<'a, T>(value: &T, rules: &'a [Box<dyn Rule<T>>]) -> Option<String>.

Организуйте тесты: модульные в src/lib.rs, интеграционные в tests/rules.rs с общим кодом в tests/common/mod.rs, один doc-тест в документации к validate_all, один #[ignore]-тест. Запустите: cargo test, cargo test --test rules, cargo test -- --ignored.

Глава 2

Проекты и функциональный стиль

Урок 6 · Часть II. Углублённый Rust

Проект: консольная утилита minigrep (обзор)

Источник: ch12-00-an-io-project.html

Кратко

Глава 12 собирает уже изученное в небольшой проект — упрощённый grep. Программа читает аргументы командной строки и файл, ищет строки с подстрокой и печатает совпадения. Это учебный «мини-проект»: вы видите, как из разрозненных идей (аргументы, файлы, Result, тесты) складывается цельная утилита. Главные уроки — разделить программу на тонкий main.rs и тестируемый lib.rs, обрабатывать ошибки без паники, писать логику через TDD и слать ошибки в stderr. Дальше в Книге этот же стиль проекта встречается снова и снова.

Основное

Шаги проекта

  1. Чтение аргументов: std::env::args(). Сначала программа вообще узнаёт, что искали и в каком файле. Без аргументов утилита бессмысленна — это входная дверь CLI.
  2. Чтение файла: std::fs::read_to_string. Содержимое целиком попадает в String. Для учебного grep это просто; на огромных файлах позже обычно читают потоково, но Книга начинает с понятного шага.
  3. Рефакторинг: разбор аргументов в структуру Config, ошибки через Result. Вместо «голых» строк и паник появляется явная конфигурация и предсказуемые сообщения об ошибках.
  4. Вынесение логики в lib.rs (функция run), тестирование функции search. Поиск становится чистой функцией: вход — строки, выход — список совпадений. Такие функции легко покрыть тестами без файлов и CLI.
  5. Переменная окружения IGNORE_CASE для регистронезависимого поиска. Так пользователь включает режим без нового флага в аргументах — типичный приём Unix-утилит.
  6. Вывод ошибок в stderr через eprintln!. Результаты остаются в stdout и их можно перенаправить в файл; диагностика остаётся на экране.

Разделение ответственности

Рекомендуемая схема для бинарных проектов:

  • main.rs: вызвать разбор аргументов, вызвать run, обработать ошибку и завершить процесс с кодом. Здесь почти нет бизнес-логики — только «склейка» и выходной код процесса.
  • lib.rs: вся остальная логика, доступная для тестов. Интеграционные и модульные тесты импортируют крейт и проверяют поведение без запуска бинарника вручную.

Если смешать всё в main, тестировать станет неудобно: придётся либо парсить вывод процесса, либо дублировать код. Разделение — это инвестиция в проверяемость.

Обработка ошибок

Config::build возвращает Result<Config, &'static str>. Ошибка разбора аргументов — обычное значение, а не паника. Пользователь видит понятное сообщение («не указан путь»), а не стек трейс.

В main используется unwrap_or_else с замыканием, которое печатает сообщение и вызывает process::exit(1). Ненулевой код выхода — сигнал скриптам и CI, что программа завершилась неуспешно. Функция run возвращает Result<(), Box<dyn Error>>, оператор ? пробрасывает ошибки ввода-вывода и другие типы ошибок без длинной цепочки match. Box<dyn Error> удобен, когда ошибки разных типов нужно свести к одному возвращаемому типу.

Разработка через тесты

Сначала пишется падающий тест для search, затем — реализация. Так вы фиксируете ожидаемое поведение до кода и не «подгоняете» тест под случайную реализацию.

Сигнатура search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> показывает, что результат ссылается на contents, а не на query. Это важно для времён жизни: найденные строки — куски исходного текста файла. Если бы возвращали владеющие String, пришлось бы копировать каждую строку; ссылки дешевле и честнее отражают смысл «подстрока из contents».

stdout и stderr

Результаты — в println! (можно перенаправить в файл > output.txt), ошибки — в eprintln!, чтобы они оставались на экране. Если писать ошибки в stdout, перенаправление смешает полезный вывод с диагностикой, и скрипты сломаются. Разделение потоков — привычка хороших CLI-программ.

Примеры

// src/lib.rs
use std::{env, error::Error, fs};

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(mut args: impl Iterator<Item = String>) -> Result<Config, &'static str> {
        args.next(); // пропускаем имя программы (argv[0])

        let Some(query) = args.next() else {
            return Err("не указана строка запроса");
        };
        let Some(file_path) = args.next() else {
            return Err("не указан путь к файлу");
        };
        // Режим без учёта регистра, если переменная окружения задана
        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config { query, file_path, ignore_case })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(&config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }
    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    // Ссылки живут столько же, сколько contents
    contents.lines().filter(|line| line.contains(query)).collect()
}

pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let query = query.to_lowercase();
    contents
        .lines()
        .filter(|line| line.to_lowercase().contains(&query))
        .collect()
}

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

    #[test]
    fn one_result() {
        let contents = "Rust:\nбыстро, безопасно.\nЛюбой выбор.";
        assert_eq!(vec!["быстро, безопасно."], search("безопасно", contents));
    }

    #[test]
    fn case_insensitive() {
        let contents = "Rust:\nrUST любит тесты.\nЖивой пример.";
        assert_eq!(
            vec!["Rust:", "rUST любит тесты."],
            search_case_insensitive("rust", contents)
        );
    }
}
// src/main.rs
use std::{env, process};

use minigrep::Config;

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Ошибка разбора аргументов: {err}");
        process::exit(1);
    });

    if let Err(e) = minigrep::run(config) {
        eprintln!("Ошибка приложения: {e}");
        process::exit(1);
    }
}

Важно запомнить

Бинарный проект лучше строить как тонкий main.rs плюс логика в lib.rs — так код можно тестировать без запуска процесса вручную.

Ошибки конфигурации и ввода-вывода возвращайте через Result, а не паникуйте: пользователь получит понятное сообщение, а скрипты — ненулевой код выхода.

В main обрабатывайте ошибку на верхнем уровне и завершайте процесс через process::exit с ненулевым кодом.

Box<dyn Error> удобен для ошибок разных типов; оператор ? приводит их автоматически к этому типу.

Данные пишите в stdout (println!), диагностику — в stderr (eprintln!), чтобы перенаправление вывода не прятало ошибки.

TDD в Книге выглядит так: сначала падающий тест, потом реализация, затем рефакторинг без смены поведения.

Урок 7 · Часть II. Углублённый Rust

Замыкания

Источник: ch13-01-closures.html

Кратко

Замыкание — анонимная функция, которую можно сохранить в переменной или передать в другую функцию. В отличие от обычных fn, замыкание может «запомнить» переменные из окружающего кода — как записка с поручением, в которую уже вложили нужные данные. Типы параметров и результата обычно выводятся автоматически, поэтому синтаксис короткий: |x| x + 1. Замыкания — основа итераторов, колбэков и передачи работы в потоки. Понимание того, как замыкание захватывает данные и какой трейт Fn/FnMut/FnOnce оно реализует, важно для чтения сигнатур стандартной библиотеки.

Основное

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

|x| x + 1, |x: u32| -> u32 { x + 1 }. Аннотации необязательны: компилятор чаще всего сам понимает типы из контекста. Фигурные скобки нужны, когда тело длиннее одного выражения.

Тип параметров фиксируется по первому использованию: вызвать одно и то же замыкание сначала с i32, потом со String нельзя. После первого вызова тип «запечатан». Если нужна гибкость по типам — пишите обобщённую функцию, а не одно замыкание на все случаи.

Способы захвата

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

  • неизменяемая ссылка (&T) — если значение только читается. Снаружи переменную по-прежнему можно читать (с обычными правилами заимствования). Это самый «дешёвый» и частый случай.
  • изменяемая ссылка (&mut T) — если тело меняет захваченное. Пока замыкание живо и может вызываться, снаружи нельзя параллельно менять ту же переменную иначе — иначе нарушатся правила заимствования.
  • перемещение (владение) — если значение потребляется (drop, возврат, передача по значению). После такого захвата исходная переменная снаружи уже недоступна: владение ушло внутрь замыкания.

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

Трейты Fn

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

  • FnOnce — можно вызвать хотя бы один раз; реализуют все замыкания. Если тело перемещает захваченное значение наружу (например, возвращает String, которой владеет), реализуется только FnOnce: второй вызов был бы невозможен — значение уже ушло.
  • FnMut — не перемещает захваченное наружу, но может изменять его; вызывается много раз. Типичный пример — счётчик внутри замыкания или sort_by_key.
  • Fn — не изменяет и не перемещает захваченное; вызывается много раз, в том числе через общую ссылку. Самое «спокойное» замыкание: только читает окружение или вообще ничего не захватывает.

Каждый следующий трейт строже предыдущего: Fn ⊂ FnMut ⊂ FnOnce. Функции-параметры указывают самое слабое достаточное ограничение. Например, Option::unwrap_or_else принимает FnOnce (замыкание вызовут не больше одного раза, если значение None), а slice::sort_by_key — FnMut (ключ считают много раз и могут менять состояние). Если вы потребуете Fn там, где хватило бы FnOnce, часть полезных замыканий просто не примут.

Передача и возврат

Замыкание как параметр: fn apply<F: Fn(i32) -> i32>(f: F). Обобщение F мономорфизуется: для каждого конкретного замыкания компилятор сгенерирует свой код — быстро и без виртуальных вызовов.

Возвращать замыкание можно как impl Fn(i32) -> i32 (один конкретный тип, скрытый от вызывающего) или Box<dyn Fn(i32) -> i32> (разные замыкания в одной сигнатуре за счёт динамической диспетчеризации). Без move вернуть замыкание, захватывающее локальные данные, обычно нельзя: ссылки умрут вместе с кадром стека.

Примеры

use std::thread;

fn apply<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 {
    f(x)
}

fn call_twice<F: FnMut()>(mut f: F) {
    f();
    f();
}

fn consume<F: FnOnce() -> String>(f: F) -> String {
    f() // вызов один раз — значение из замыкания можно «забрать»
}

fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
    move |x| x + n // move: n уезжает вместе с замыканием
}

fn make_boxed(flag: bool) -> Box<dyn Fn(i32) -> i32> {
    if flag {
        Box::new(|x| x * 2)
    } else {
        Box::new(|x| x + 100)
    }
}

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

fn main() {
    // Вывод типов по первому использованию
    let add_one = |x| x + 1;
    println!("{}", add_one(5));

    // Неизменяемый захват: list снаружи остаётся доступен
    let list = vec![1, 2, 3];
    let only_borrows = || println!("{list:?}");
    only_borrows();
    println!("{list:?}");

    // Изменяемый захват (FnMut)
    let mut counter = 0;
    call_twice(|| counter += 1);
    println!("counter = {counter}"); // 2

    // Перемещение: только FnOnce
    let s = String::from("владение");
    println!("{}", consume(move || s));

    // move обязателен для потока: данные не должны жить «ссылкой на main»
    let data = vec![1, 2, 3];
    let handle = thread::spawn(move || println!("в потоке: {data:?}"));
    handle.join().unwrap();

    println!("{}", apply(make_adder(10), 5));
    println!("{}", make_boxed(true)(21));

    // sort_by_key принимает FnMut — ключ считают много раз
    let mut rects = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
    ];
    rects.sort_by_key(|r| r.width);
    println!("{rects:#?}");
}

Важно запомнить

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

Способ захвата (ссылка, изменяемая ссылка, владение) компилятор выбирает по минимуму необходимого; move задаёт владение явно, когда нужна независимость от исходной области видимости.

Трейты Fn, FnMut и FnOnce описывают, что замыкание делает с захваченными значениями при вызове, а не только как оно их взяло.

В параметрах указывайте наименее строгий подходящий трейт: FnOnce — самый мягкий, Fn — самый строгий.

Для возврата замыканий используйте impl Fn… (один тип) или Box<dyn Fn…> (разные типы за одним интерфейсом).

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

Урок 8 · Часть II. Углублённый Rust

Итераторы

Источник: ch13-02-iterators.html

Кратко

Итератор обрабатывает последовательность элементов по одному — как конвейер: детали едут по ленте, и на каждом участке с ними что-то делают. В Rust итераторы ленивы: пока не вызван потребляющий метод (collect, sum, for и т. п.), вычисления не начинаются. Можно собрать длинную цепочку filter/map и ничего не заплатить, пока не «включите» конвейер. Такая модель обычно читается лучше ручных циклов с индексами, а оптимизатор часто превращает её в код сравнимой скорости (абстракции с нулевой стоимостью).

Основное

Трейт Iterator

trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    // множество методов по умолчанию
}

Достаточно реализовать next: остальные методы (map, filter, sum, …) предоставляются автоматически. Это мощный приём: один метод открывает целый «комбайн» адаптеров.

next изменяет внутреннее состояние (сдвигает «курсор»), поэтому итератор для ручного вызова должен быть mut. Когда элементы кончились, next возвращает None — сигнал остановки. Цикл for как раз крутит next, пока не увидит None.

Три способа получить итератор

  • iter() — элементы &T. Коллекция остаётся вашей; вы только читаете. Подходит для большинства обходов «посмотреть и посчитать».
  • iter_mut() — элементы &mut T. Можно менять элементы на месте. Одновременно нельзя держать другую изменяемую или неизменяемую ссылку на ту же коллекцию по правилам заимствования.
  • into_iter() — элементы T (владение). Коллекция «разбирается»: после этого исходный Vec и т. п. использовать нельзя. Нужно, когда элементы надо унести дальше (в другую структуру, в поток, в collect владеющих значений).

Цикл for вызывает into_iter на том, что вы ему дали, и сам управляет вызовами next. Для вектора for x in &v даёт ссылки, for x in &mut v — изменяемые ссылки, for x in v — владение элементами.

Потребляющие адаптеры

Вызывают next до конца (или до ранней остановки) и расходуют итератор: sum, count, collect, fold, min, max, any, all, find, position. После них исходный итератор обычно больше нельзя использовать — он «съеден».

Именно потребляющий метод запускает всю ленивую цепочку. Без него map и filter — только описание будущей работы, а не выполненная работа. Если забыли collect и программа «ничего не делает» — часто причина в ленивости.

Преобразующие адаптеры

Возвращают новый итератор и ничего не вычисляют сами: map, filter, enumerate, zip, take, skip, chain, rev, flat_map, take_while, peekable. Их можно нанизывать друг на друга, как фильтры на конвейере.

Результат нужно потребить, обычно через collect или sum/for. Тип результата collect часто нужно указать (Vec<_>, HashMap<_, _> и т. д.), иначе компилятор не знает, во что собирать.

Захват окружения в filter

Замыкание в filter может использовать внешние переменные, например порог или размер. Это естественно сочетается с главой о замыканиях: фильтр «закрывает» нужный критерий из окружения. Без захвата пришлось бы городить отдельные типы или глобальные данные.

Производительность

Цикл на итераторах и ручной цикл компилируются почти одинаково: оптимизатор разворачивает циклы и убирает проверки границ, где это безопасно. Не нужно заранее бояться, что map/filter «медленнее индекса». Пишите ясно; профилируйте, если узкое место доказано измерениями.

Собственный итератор

Достаточно определить ассоциированный тип Item и метод next. Все адаптеры стандартной библиотеки сразу становятся доступны вашему типу. Так в Книге построен учебный Counter: после next на нём работают zip, map, filter, sum без дополнительного кода.

Примеры

#[derive(Debug, PartialEq)]
struct Shoe {
    size: u32,
    style: String,
}

fn shoes_in_size(shoes: Vec<Shoe>, size: u32) -> Vec<Shoe> {
    // into_iter: забираем владение обувью, filter оставляет нужный размер
    shoes.into_iter().filter(|s| s.size == size).collect()
}

struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<u32> {
        if self.count < 5 {
            self.count += 1;
            Some(self.count)
        } else {
            None // конец последовательности
        }
    }
}

fn main() {
    let v = vec![1, 2, 3];

    // Ручной обход: нужен mut
    let mut it = v.iter();
    assert_eq!(it.next(), Some(&1));

    // Потребляющий адаптер запускает обход
    let total: i32 = v.iter().sum();
    println!("{total}");

    // Без collect map ничего не посчитает (ленивость)
    let doubled: Vec<i32> = v.iter().map(|x| x * 2).collect();
    println!("{doubled:?}");

    // Цепочка адаптеров
    let sum_of_squares: u32 = (1..=10).filter(|n| n % 2 == 0).map(|n| n * n).sum();
    println!("{sum_of_squares}");

    // collect в Result: остановка на первой ошибке разбора
    let parsed: Result<Vec<i32>, _> = "1 2 x".split(' ').map(str::parse::<i32>).collect();
    println!("{}", parsed.is_err());

    for (i, x) in v.iter().enumerate() {
        println!("{i}: {x}");
    }
    let dot: i32 = v.iter().zip([4, 5, 6]).map(|(a, b)| a * b).sum();
    let product = v.iter().fold(1, |acc, x| acc * x);
    println!("{dot} {product}");

    let mut nums = vec![1, 2, 3];
    nums.iter_mut().for_each(|n| *n += 10);
    println!("{nums:?}");

    let s: u32 = Counter::new()
        .zip(Counter::new().skip(1))
        .map(|(a, b)| a * b)
        .filter(|x| x % 3 == 0)
        .sum();
    println!("{s}"); // 18

    let shoes = vec![
        Shoe { size: 10, style: "кроссовки".into() },
        Shoe { size: 13, style: "сандалии".into() },
    ];
    println!("{:?}", shoes_in_size(shoes, 10));
}

Важно запомнить

Итераторы ленивы: преобразующие адаптеры сами по себе ничего не считают, пока не вызовут потребляющий метод (collect, sum, цикл for и другие).

iter() даёт &T, iter_mut() — &mut T, into_iter() — владеющие T; выбирайте способ по тому, нужно ли сохранить коллекцию и можно ли её менять.

Для собственного итератора реализуют только next (и тип Item); остальные методы трейта приходят бесплатно.

collect умеет собирать в Vec, HashMap, String, Result<Vec<_>, _> и другие типы — целевой тип часто нужно указать явно.

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

Урок 9 · Часть II. Углублённый Rust

Подробнее о Cargo и crates.io

Источник: ch14-00-more-about-cargo.html

Кратко

Cargo умеет больше, чем «собрать и запустить»: настраивает профили оптимизации, публикует пакеты на crates.io, объединяет несколько пакетов в workspace, ставит бинарные утилиты и расширяется пользовательскими подкомандами. Глава 14 Книги как раз про этот «взрослый» слой экосистемы. Без него вы пишете код; с ним — оформляете библиотеку так, чтобы ею могли пользоваться другие, и держите связанные крейты в одном репозитории. Ниже — профили, документация, pub use, публикация, workspaces и cargo install.

Основное

Профили сборки

Два основных профиля: dev (cargo build, по умолчанию opt-level = 0, быстрая компиляция) и release (cargo build --release, opt-level = 3, быстрый код). В разработке важна скорость пересборки; в продакшене — скорость выполнения. Это разные цели, поэтому профили разделены.

Настраиваются в Cargo.toml секциями [profile.dev] и [profile.release]. Можно поднять opt-level в dev чуть выше нуля, если отлаживаете производительность, но полная оптимизация релиза обычно дольше собирается. Меняйте осознанно: «всегда release локально» замедляет цикл правок.

Документирующие комментарии

/// документирует следующий элемент и поддерживает Markdown; //! документирует содержащий элемент (часто весь крейт в lib.rs). cargo doc --open строит HTML и открывает его в браузере — это стандартный способ читать API своей и чужих библиотек.

Типичные разделы: # Examples, # Panics, # Errors, # Safety. Они помогают читателю быстро найти риски и примеры. Блоки кода в документации запускаются как тесты (cargo test): устаревший пример сломает сборку тестов, и вы заметите расхождение с реальным API.

pub use

Реэкспорт упрощает публичный API: пользователю не нужно знать внутреннюю структуру модулей. pub use self::kinds::PrimaryColor; делает тип доступным на верхнем уровне крейта.

Внутри вы можете спокойно раскладывать код по модулям для себя; снаружи оставляете короткий и стабильный «фасад». Если каждый раз заставлять писать art::kinds::PrimaryColor, API кажется шумным и хрупким к перестановке модулей.

Публикация на crates.io

  1. Создать аккаунт (через GitHub) и получить токен API, выполнить cargo login. Без токена cargo publish не сможет подтвердить, что пакет публикуете вы.
  2. Заполнить метаданные в [package]: уникальное name, version, edition, description, license (SPDX-идентификатор, например MIT OR Apache-2.0). Имя должно быть свободно на crates.io; лицензия обязательна для публикации.
  3. cargo publish. Публикация постоянна: версию нельзя перезаписать или удалить, только скрыть для новых зависимостей. Относитесь к релизу как к записи в общей книге — исправления только новой версией.
  4. Новая версия — поднять version по SemVer и снова cargo publish. Ломающие изменения — мажорная версия; обратно совместимые фичи — минорная; только правки — патч.
  5. cargo yank --vers 1.0.1 запрещает новые зависимости от версии, но существующие проекты с уже зафиксированным Cargo.lock продолжают работать; --undo отменяет yank. Yank — «не берите это для новых проектов», а не полное стирание из истории.

Workspaces

Рабочее пространство — набор пакетов с общим Cargo.lock и каталогом target. Так несколько связанных крейтов живут в одном репозитории без дублирования блокировок зависимостей. Корневой Cargo.toml:

[workspace]
members = ["adder", "add_one"]
resolver = "2"

Зависимости между локальными крейтами указываются через path: add_one = { path = "../add_one" }. Запуск: cargo run -p adder, тесты: cargo test -p add_one. Флаг -p выбирает пакет внутри workspace.

Внешние зависимости записываются отдельно в каждом Cargo.toml, но версии согласуются общим Cargo.lock. Это снижает риск «у меня в одном пакете rand 0.8, в другом — другая линия», пока вы работаете вместе.

cargo install

Устанавливает бинарные крейты из crates.io в ~/.cargo/bin (каталог должен быть в PATH): cargo install ripgrep. Получаете готовые CLI-инструменты, написанные на Rust, без ручной сборки из git.

Это не замена системному менеджеру пакетов дистрибутива, а способ поставить инструменты для Rust-разработки и сопутствующие утилиты. Обновление — снова cargo install с нужной версией или флагами обновления.

Пользовательские команды

Любой исполняемый файл cargo-something в PATH вызывается как cargo something; cargo --list показывает такие команды. Так расширяют Cargo без правки самого Cargo: формат имени cargo-* — договорённость экосистемы.

Примеры

# Cargo.toml
[package]
name = "my_unique_crate_name"
version = "0.1.0"
edition = "2021"
description = "Небольшая библиотека для примера"
license = "MIT OR Apache-2.0"

[dependencies]
rand = "0.8"

[profile.dev]
opt-level = 1

[profile.release]
opt-level = 3
//! # art
//!
//! Библиотека для моделирования художественных понятий.

pub use self::kinds::PrimaryColor;
pub use self::utils::mix;

pub mod kinds {
    /// Основные цвета по модели RYB.
    #[derive(Debug, PartialEq)]
    pub enum PrimaryColor {
        Red,
        Yellow,
        Blue,
    }
}

pub mod utils {
    use crate::kinds::PrimaryColor;

    /// Смешивает два основных цвета.
    ///
    /// # Examples
    ///
    /// ```
    /// use art::{mix, PrimaryColor};
    ///
    /// assert_eq!(mix(PrimaryColor::Red, PrimaryColor::Red), PrimaryColor::Red);
    /// ```
    pub fn mix(c1: PrimaryColor, _c2: PrimaryColor) -> PrimaryColor {
        c1
    }
}

Важно запомнить

Профиль dev оптимизирован под быструю сборку, release — под скорость кода; тонкая настройка делается в секциях [profile.*].

Комментарии /// и //! строят документацию; примеры в них автоматически проверяются как тесты.

pub use отделяет удобный публичный фасад от внутренней раскладки модулей.

Публикация версии на crates.io необратима в смысле «перезаписать нельзя»; cargo yank лишь блокирует новые зависимости от этой версии.

Workspace объединяет пакеты с общим Cargo.lock и target; флаг -p выбирает конкретный пакет для команд.

cargo install ставит бинарные утилиты в ~/.cargo/bin; исполняемый файл cargo-xxx в PATH становится подкомандой cargo xxx.

Практика 2 · Часть II. Углублённый Rust

Практика: замыкания, итераторы, Cargo

Задания для закрепления темы · решений нет

Темы: 05-01–05-04.

Задание 1. Применение функции

Сложность: лёгкое

Напишите fn apply_n<F: Fn(i32) -> i32>(f: F, n: usize, x: i32) -> i32, применяющую f к x ровно n раз. Проверьте с замыканием |v| v * 2 и с замыканием, захватывающим переменную step из окружения.

Задание 2. Конвейер итераторов

Сложность: лёгкое

Дан Vec<i32>. Одним выражением на итераторах вычислите сумму квадратов всех нечётных положительных чисел. Затем соберите в Vec<String> строки вида "1: 9" (индекс и значение) с помощью enumerate, map, collect. Не используйте циклы for.

Задание 3. Счётчик вызовов (FnMut)

Сложность: среднее

Напишите fn make_counter() -> impl FnMut() -> u32, возвращающую замыкание, которое при каждом вызове возвращает 1, 2, 3… Затем напишите fn call_n<F: FnMut() -> u32>(mut f: F, n: usize) -> Vec<u32>. Объясните в комментарии, почему замыканию нужен move, а Fn здесь не подходит.

Задание 4. Собственный итератор

Сложность: среднее

Реализуйте итератор Fib, выдающий числа Фибоначчи (u64) и завершающийся при переполнении (используйте checked_add). Примените цепочку Fib::new().take_while(|&x| x < 1000).filter(|x| x % 2 == 0).sum::<u64>(). Проверьте, что Fib::new().take(10).collect::<Vec<_>>() равно [0, 1, 1, 2, 3, 5, 8, 13, 21, 34].

Задание 5. Частотный анализ слов

Сложность: сложное

Напишите функцию fn top_words(text: &str, n: usize) -> Vec<(String, usize)>: приведите слова к нижнему регистру, отбросьте знаки пунктуации (char::is_alphanumeric), подсчитайте частоты в HashMap (используйте fold или entry), отсортируйте по убыванию частоты, затем по алфавиту, и верните первые n. Реализуйте без явных циклов for везде, где это возможно, и добавьте юнит-тесты (в том числе пустой текст и n больше числа слов).

Задание 6. Мини-minigrep с workspace

Сложность: сложное

Создайте workspace из двух крейтов: библиотека search и бинарник cli.

  • search предоставляет pub fn search<'a>(query: &str, contents: &'a str, ignore_case: bool) -> Vec<&'a str> с документацией и doc-тестом.
  • cli разбирает аргументы (std::env::args), читает файл, ошибки пишет в stderr через eprintln! и завершается с кодом 1.
  • Корневой Cargo.toml содержит [workspace] и resolver = "2", а также [profile.release] opt-level = 3.

Проверьте: cargo test -p search, cargo run -p cli -- rust text.txt, cargo doc --open. Подготовьте метаданные (description, license) для search, как для публикации, и выполните cargo publish --dry-run.

Глава 3

Продвинутые темы

Урок 10 · Часть II. Углублённый Rust

Умные указатели

Источник: ch15-00-smart-pointers.html

Кратко

Умный указатель — структура, которая ведёт себя как указатель, но несёт дополнительные метаданные и поведение и обычно владеет данными. Обычная ссылка только заимствует; умный указатель отвечает за жизнь значения (когда создать, когда освободить). String и Vec<T> — тоже умные указатели: они владеют буфером в куче и умеют расти. Ключевые трейты здесь — Deref (чтобы тип «вёл себя как ссылка») и Drop (код при выходе из области видимости). Дальше Книга разбирает Box, Rc, RefCell и Weak — инструменты для кучи, совместного владения и внутренней изменяемости.

Основное

Box<T>

Размещает значение в куче; на стеке остаётся только маленький указатель фиксированного размера. Это нужно, когда сам тип «не влезает» в стековый кадр по правилам компилятора или когда выгодно не копировать большие данные при передаче владения.

Применяется:

  • для типов с неизвестным заранее размером, прежде всего рекурсивных (enum List { Cons(i32, Box<List>), Nil }). Без Box размер List включал бы ещё один List бесконечно — компилятор не смог бы выделить место. Box «ломает» рекурсию: внутри лежит указатель известного размера;
  • для передачи владения большими данными без копирования содержимого — копируется только указатель;
  • для трейт-объектов Box<dyn Trait>, когда конкретный тип скрыт за общим интерфейсом и живёт в куче.

Трейт Deref

Реализация Deref позволяет использовать *x для своего типа так же, как для обычной ссылки. Метод deref возвращает ссылку на внутренние данные; компилятор подставляет нужное число разыменований.

Deref coercion автоматически превращает &MyBox<String> в &String и далее в &str, поэтому hello(&m) работает без ручных разыменований. Без этого пришлось бы писать неуклюжее hello(&(*m)[..]). Для изменяемых ссылок есть DerefMut. Принудительные приведения: &T → &U при T: Deref<Target=U>, &mut T → &mut U при DerefMut, а также &mut T → &U (изменяемую ссылку можно сузить до неизменяемой, наоборот — нельзя).

Трейт Drop

Метод drop(&mut self) вызывается автоматически при выходе значения из области видимости, в порядке, обратном объявлению (последний созданный — первый уничтоженный). Так освобождают файлы, сокеты, память в куче.

Явно вызывать x.drop() нельзя — метод Drop::drop особый. Для раннего освобождения используется std::mem::drop(x): это обычная функция, которая забирает владение и сразу выходит из области, запуская деструктор. Нужно, когда ресурс надо отпустить до конца блока (закрыть файл раньше, уменьшить счётчик Rc и т. п.).

Rc<T>

Подсчёт ссылок для нескольких владельцев в одном потоке. Пока счётчик больше нуля, данные живы; когда последний Rc уничтожен — данные освобождаются. Rc::clone(&a) увеличивает счётчик (дёшево, данные не копируются), Rc::strong_count(&a) показывает значение.

Данные внутри Rc по умолчанию неизменяемы: несколько владельцев + свободная запись дали бы гонки даже в одном потоке с точки зрения правил Rust. Для многопоточности нужен атомарный аналог — Arc<T>. Rc в другой поток не отправить: он не реализует Send.

RefCell<T> и внутренняя изменяемость

Правила заимствования проверяются во время выполнения, а не компиляции: borrow() даёт стража неизменяемого доступа, borrow_mut() — изменяемого. При нарушении (две изменяемые «ссылки» сразу) программа паникует. Вы меняете проверку «на этапе компиляции» на проверку «в рантайме» — гибкость ценой возможной паники.

Это паттерн внутренней изменяемости: снаружи у вас неизменяемая ссылка/Rc, а внутри данные всё же можно менять через RefCell. Только для однопоточного кода; для потоков Книга указывает на Mutex. Типичная комбинация — Rc<RefCell<T>>: несколько владельцев, каждый может менять общие данные в одном потоке.

Циклы ссылок и Weak<T>

Два Rc, указывающих друг на друга, не освободятся: сильный счётчик никогда не станет нулём — классическая утечка памяти. Компилятор это не ловит: с точки зрения типов всё «законно».

Решение: Rc::downgrade(&rc) создаёт Weak<T> (не владеет, сильный счётчик не увеличивает), weak.upgrade() возвращает Option<Rc<T>> — Some, если данные ещё живы, None, если уже освобождены. Классический пример — дерево: дети принадлежат родителю через Rc, родитель известен ребёнку через Weak, чтобы не замкнуть цикл владения.

| Тип | Владельцы | Проверка заимствований |

|---|---|---|

| Box<T> | один | компиляция |

| Rc<T> | много | компиляция (только чтение) |

| RefCell<T> | один | выполнение |

Примеры

use std::cell::RefCell;
use std::ops::Deref;
use std::rc::{Rc, Weak};

// Рекурсивный тип: без Box размер List был бы бесконечным
#[derive(Debug)]
enum List {
    Cons(i32, Box<List>),
    Nil,
}
use List::{Cons, Nil};

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

impl<T> Deref for MyBox<T> {
    type Target = T;
    fn deref(&self) -> &T {
        &self.0
    }
}

fn hello(name: &str) {
    println!("Привет, {name}!");
}

struct Noisy(&'static str);

impl Drop for Noisy {
    fn drop(&mut self) {
        println!("drop {}", self.0);
    }
}

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Nil))));
    println!("{list:?}");

    let m = MyBox::new(String::from("Rust"));
    hello(&m); // deref coercion: &MyBox<String> -> &str
    assert_eq!(5, *MyBox::new(5));

    let _a = Noisy("a");
    let b = Noisy("b");
    drop(b); // раннее освобождение через std::mem::drop
    println!("конец main");

    let shared = Rc::new(String::from("общие данные"));
    let c1 = Rc::clone(&shared); // дешёвое увеличение счётчика
    println!("счётчик = {}", Rc::strong_count(&shared)); // 2
    drop(c1);
    println!("счётчик = {}", Rc::strong_count(&shared)); // 1

    let value = Rc::new(RefCell::new(5));
    let other = Rc::clone(&value);
    *other.borrow_mut() += 10;
    println!("{}", value.borrow()); // 15

    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });
    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);
    println!("родитель листа = {:?}", leaf.parent.borrow().upgrade().map(|p| p.value));
    println!("strong={}, weak={}", Rc::strong_count(&branch), Rc::weak_count(&branch));
}

Важно запомнить

Box<T> даёт одно владельческое значение в куче и нужен для рекурсивных типов и Box<dyn Trait>.

Deref задаёт поведение оператора *, а deref coercion автоматически приводит ссылки на умные указатели к ссылкам на содержимое — так удобно стыковать API, ждущие &str и подобные типы.

Drop выполняется автоматически при выходе из области; для раннего освобождения вызывайте drop(x), а не x.drop().

Rc<T> позволяет нескольким владельцам в одном потоке без изменяемости данных; для потоков используйте Arc<T>.

RefCell<T> переносит проверку правил заимствования на время выполнения; нарушение приводит к панике, зато появляется внутренняя изменяемость.

Циклы из сильных Rc приводят к утечкам памяти; разрывайте их через Weak<T> и upgrade().

Урок 11 · Часть II. Углублённый Rust

Многопоточность без страха

Источник: ch16-00-concurrency.html

Кратко

Система владения и типов Rust превращает многие ошибки конкурентности — гонки данных, использование памяти после освобождения в другом потоке — в ошибки компиляции. Вы не «надеетесь», что гонки не будет: компилятор отказывается собрать опасный код. Глава Книги рассматривает потоки, передачу сообщений через каналы, разделяемое состояние (Mutex, Arc) и маркерные трейты Send и Sync. Идея простая: либо данные едут сообщением (владение переходит), либо доступ к общим данным строго сериализуется мьютексом.

Основное

Потоки

thread::spawn(замыкание) запускает поток и возвращает JoinHandle. handle.join().unwrap() ждёт завершения и возвращает результат замыкания (или пробрасывает панику потока). Без join главный поток может закончиться раньше и оборвать остальные — работа «тихо» не завершится.

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

Каналы

mpsc (multiple producer, single consumer): let (tx, rx) = mpsc::channel();. Один приёмник, много отправителей — удобная модель «очередь заданий» или «поток событий».

  • tx.send(val) перемещает значение: после отправки использовать его в отправителе нельзя. Так исключается ситуация «я отправил и одновременно ещё меняю то же значение».
  • rx.recv() блокируется, пока не придёт сообщение или не закроются все отправители; rx.try_recv() не блокируется и сразу говорит, есть ли данные.
  • for received in rx читает, пока все отправители не закрыты. Когда Sender уничтожены, цикл заканчивается — естественный способ дождаться «конца потока сообщений».
  • Несколько производителей: let tx1 = tx.clone();. Клонируется отправитель, не данные; приёмник по-прежнему один.

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

Mutex<T>

Взаимное исключение: доступ к данным только через lock(), возвращающий MutexGuard. Пока гард жив, другие потоки ждут у lock(). Гард разблокирует мьютекс при выходе из области (Drop) — даже если вы забыли «закрыть замок» вручную, RAII сделает это за вас.

lock() может вернуть Err, если другой поток запаниковал, удерживая блокировку (poisoning): мьютекс помечается «отравленным», чтобы вы не продолжали работу с возможно повреждённым состоянием. Риск, который типы не снимают полностью, — взаимные блокировки (deadlock), если потоки захватывают несколько мьютексов в разном порядке. Здесь нужна дисциплина порядка блокировок.

Arc<T>

Rc<T> небезопасен для потоков: счётчик ссылок не атомарный. Для разделяемого владения между потоками используется атомарный Arc<T>; типичное сочетание — Arc<Mutex<T>>. Несколько потоков владеют одним счётчиком (Arc), а доступ к данным сериализует Mutex.

Mutex<T> даёт внутреннюю изменяемость, как RefCell<T>, но потокобезопасную. Аналогия: RefCell — замок на одной кухне; Mutex — замок, который видят все повара в разных комнатах.

Send и Sync

  • Send — владение значением можно передавать между потоками. Почти все типы Send; заметное исключение — Rc<T> (и связанные с ним типы без атомарности).
  • Sync — на значение можно безопасно ссылаться из нескольких потоков одновременно (&T является Send, если T: Sync). Исключения: Rc<T>, RefCell<T>, Cell<T> и ряд других типов с неатомарной внутренней логикой.

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

Область потоков

thread::scope позволяет потокам заимствовать локальные данные без move и Arc, так как все порождённые в области потоки гарантированно завершаются до выхода из scope. Компилятор знает: ссылки на локальные переменные не переживут блок. Это удобно для короткого параллелизма над данными на стеке вызывающего.

Примеры

use std::sync::{mpsc, Arc, Mutex};
use std::thread;
use std::time::Duration;

fn main() {
    // Без join главный поток может оборвать работу раньше времени
    let handle = thread::spawn(|| {
        for i in 1..4 {
            println!("поток: {i}");
            thread::sleep(Duration::from_millis(1));
        }
    });
    handle.join().unwrap();

    // move: вектор уезжает в поток целиком
    let v = vec![1, 2, 3];
    let h = thread::spawn(move || println!("вектор: {v:?}"));
    h.join().unwrap();

    let (tx, rx) = mpsc::channel();
    let tx1 = tx.clone();
    thread::spawn(move || {
        tx.send(String::from("от первого")).unwrap();
    });
    thread::spawn(move || {
        tx1.send(String::from("от второго")).unwrap();
    });
    // Цикл завершится, когда оба Sender уничтожены
    for received in rx {
        println!("получено: {received}");
    }

    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];
    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            *counter.lock().unwrap() += 1;
        }));
    }
    for h in handles {
        h.join().unwrap();
    }
    println!("итог: {}", *counter.lock().unwrap()); // 10

    // scope: можно заимствовать data без Arc/move
    let mut data = vec![1, 2, 3];
    thread::scope(|s| {
        s.spawn(|| println!("длина: {}", data.len()));
    });
    data.push(4);
    println!("{data:?}");
}

Важно запомнить

thread::spawn возвращает JoinHandle; без join поток может не успеть завершиться до конца программы.

Для данных из окружения в долгий поток используйте move, а для короткого параллелизма над локальными данными — thread::scope.

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

Разделяемое изменяемое состояние в Книге — связка Arc<Mutex<T>>; Rc и RefCell для потоков не подходят.

Трейты Send и Sync проверяются компилятором автоматически и отсекают опасную передачу типов вроде Rc и RefCell.

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

Урок 12 · Часть II. Углублённый Rust

Возможности ООП в Rust

Источник: ch18-00-oop.html

Кратко

Rust не является классическим ООП-языком: структуры не наследуют поля и методы других структур, как классы в Java или C++. Зато Rust поддерживает инкапсуляцию (приватные поля, публичные методы) и полиморфизм — через обобщения с трейтами и через трейт-объекты dyn Trait. Думайте об этом как о «наборе инструментов ООП без дерева наследования классов». Глава также показывает паттерн «состояние» двумя способами: динамически через dyn State и статически через разные типы для каждого состояния — второй вариант часто идиоматичнее для Rust.

Основное

Инкапсуляция

Поля структуры приватны по умолчанию; публичное поведение выставляется через pub-методы. Снаружи нельзя напрямую сломать инварианты, записав в поле что угодно. Это основа модульности: вы можете менять внутреннее представление (другая структура данных, кэш, ленивый расчёт), не ломая код пользователей, пока сигнатуры методов стабильны.

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

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

Структуры не наследуют поля и методы других структур. Повторное использование поведения достигается методами трейта по умолчанию: написали реализацию один раз в трейте — все типы с impl Trait for Type получают её, пока не переопределят.

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

Трейт-объекты

Vec<Box<dyn Draw>> хранит значения разных типов, реализующих Draw. На экране могут лежать и кнопка, и список — общий интерфейс draw. Отличие от обобщений: обобщённый Screen<T: Draw> или Vec<T> содержит один конкретный тип на всю коллекцию, а dyn Trait допускает разные типы во время выполнения в одном векторе.

  • Статическая диспетчеризация (обобщения): мономорфизация, нет накладных расходов на виртуальный вызов, но дублирование кода для каждого T. Компилятор «подставляет» конкретный тип и часто может заинлайнить методы.
  • Динамическая диспетчеризация (dyn): вызов через таблицу виртуальных методов (vtable), небольшая стоимость вызова, нет инлайнинга. Платите гибкостью разнородных коллекций.

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

Объектная безопасность

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

Пример объектно-небезопасного трейта — Clone (fn clone(&self) -> Self): возвращаемый тип разный у каждой реализации и не может быть единым для dyn Clone. Если нужен клон через трейт-объект, обычно делают отдельный метод, возвращающий Box<dyn Trait>.

Паттерн «состояние»

Объект хранит Option<Box<dyn State>>; каждое состояние решает, в какое состояние перейти. Методы состояния принимают self: Box<Self>, чтобы потребить старое состояние и вернуть новое — без двух живых состояний сразу.

В Post метод request_review делает self.state.take(), чтобы временно вынуть состояние из Option (оставить None), передать владение методу перехода и положить результат обратно. Без take нельзя было бы переместить Box из &mut self. Это рабочий, но довольно «шумный» динамический вариант паттерна.

Состояния как типы

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

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

Примеры

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    // Разные типы за одним интерфейсом
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for c in &self.components {
            c.draw();
        }
    }
}

pub struct Button {
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        println!("[{}]", self.label);
    }
}

struct SelectBox {
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        println!("<{}>", self.options.join("|"));
    }
}

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post { state: Some(Box::new(Draft {})), content: String::new() }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }

    pub fn request_review(&mut self) {
        // take() забирает состояние, чтобы переместить его в метод перехода
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review());
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve());
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
    fn content<'a>(&self, _post: &'a Post) -> &'a str {
        ""
    }
}

struct Draft {}
struct Pending {}
struct Published {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(Pending {})
    }
    fn approve(self: Box<Self>) -> Box<dyn State> {
        self // из черновика сразу публиковать нельзя — остаёмся Draft
    }
}

impl State for Pending {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
    fn content<'a>(&self, post: &'a Post) -> &'a str {
        &post.content
    }
}

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(SelectBox { options: vec!["Да".into(), "Нет".into()] }),
            Box::new(Button { label: "OK".into() }),
        ],
    };
    screen.run();

    let mut post = Post::new();
    post.add_text("Сегодня я ел салат");
    assert_eq!("", post.content());
    post.request_review();
    assert_eq!("", post.content());
    post.approve();
    assert_eq!("Сегодня я ел салат", post.content());
}

Важно запомнить

В Rust нет наследования структур; общее поведение выносят в трейты и методы по умолчанию.

Box<dyn Trait> даёт динамический полиморфизм и разнородные коллекции; обобщения дают статический полиморфизм без стоимости vtable.

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

Паттерн «состояние» через Option<Box<dyn State>> и self: Box<Self> работает, но многословен и проверяет правила переходов в рантайме.

Кодирование состояний отдельными типами переносит проверку правил на этап компиляции и часто предпочтительнее динамического варианта в идиоматичном Rust.

Урок 13 · Часть II. Углублённый Rust

Паттерны и сопоставление

Источник: ch19-00-patterns.html

Кратко

Паттерны — особый синтаксис для сопоставления со структурой значений: «подходит ли это значение под такую форму, и какие куски из него достать». Они встречаются не только в match, но и в if let, while let, let, for и параметрах функций. Главное свойство — опровержимость: неопровержимый паттерн подходит всегда (как (x, y) для пары), опровержимый — только иногда (как Some(x) для Option). Путать эти два вида в неподходящем месте — частая ошибка новичков, и компилятор на этом ловит.

Основное

Где используются паттерны

  • match: ветки должны покрывать все варианты (исчерпывающе). Если забыли случай, программа не скомпилируется — это защита от «молчаливого» пропуска ветки.
  • let: let (x, y, z) = (1, 2, 3); — деструктуризация. Вы сразу раскладываете значение на части с именами, без индексов и лишних временных переменных.
  • if let, else if let, let ... else — когда интересует один (или несколько конкретных) вариант, а полный match был бы громоздким.
  • while let — цикл, пока паттерн подходит (while let Some(top) = stack.pop()). Удобно разбирать стек или очередь, пока они не пусты.
  • for (i, v) in iter.enumerate() — паттерн в заголовке цикла разбирает каждый элемент итератора.
  • Параметры функций и замыканий: fn f(&(x, y): &(i32, i32)) — аргумент сразу разбирается при входе. Это сокращает тело функции и делает контракт «жду пару» явным.

Опровержимость

let, for, параметры функций требуют неопровержимых паттернов: связывание должно всегда удаваться. if let, while let, let else как раз рассчитаны на опровержимые: есть ветка «не подошло».

Для let Some(x) = opt; без else компилятор выдаст ошибку: opt может быть None, и тогда let «сломался» бы в рантайме. Правильно либо if let Some(x) = opt, либо let Some(x) = opt else { ... }, либо полный match. Опровержимость — не каприз синтаксиса, а способ не допустить падения на неудачном разборе.

Синтаксис паттернов

  • Литералы: 1, 'a'. Точное совпадение со значением. Полезно для диспетчеризации по константам.
  • Именованные переменные (затеняют внешние внутри ветки). Имя в паттерне создаёт новую привязку; внешняя переменная с тем же именем внутри ветки не видна. Это частый источник путаницы: думали, что сравниваете с внешней x, а на самом деле создали новую.
  • Альтернативы: 1 | 2. Одна ветка на несколько одинаковых исходов — меньше копипасты.
  • Диапазоны: 1..=5, 'a'..='z' (только числа и char). Удобно для классификации «попало в интервал».
  • Деструктуризация структур: Point { x, y: 0 }, Point { x: a, y: b }. Можно одновременно достать поля и проверить конкретные значения.
  • Деструктуризация перечислений и вложенных структур: Message::Move { x, y }. Вложенность может быть глубокой — паттерн отражает форму данных.
  • Игнорирование: _ (значение не нужно), _name (имя с подчёркиванием — без предупреждения о неиспользовании, но привязка есть: владение всё равно может переместиться), .. (пропустить остальные поля или элементы). _ не перемещает «интерес» к значению так же, как именованная привязка в смысле предупреждений; важно помнить разницу с _name при владении.
  • Привязка @: id @ 3..=7 — проверить диапазон и одновременно сохранить попавшее значение в id. Без @ в диапазоне само число «теряется» как отдельная переменная.
  • Сторожевое условие (guard): Some(x) if x % 2 == 0. Дополнительная проверка после совпадения формы. Для 1 | 2 if cond условие относится ко всем альтернативам слева. Важно: компилятор не учитывает guard при проверке исчерпываемости — нужен запасной случай _ или другая ветка.
  • Ссылочные паттерны: &(x, y), а также устаревающие в новых стилях ref/ref mut (в современном коде чаще полагаются на match ergonomics). Нужны, когда разбираете ссылку и хотите работать с полями по значению или явно управлять заимствованием.

Примеры

struct Point {
    x: i32,
    y: i32,
}

enum Color {
    Rgb(i32, i32, i32),
    Hsv(i32, i32, i32),
}

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(Color),
}

fn describe(msg: Message) -> String {
    match msg {
        Message::Quit => "выход".to_string(),
        // Частный случай: x == 0
        Message::Move { x: 0, y } => format!("по вертикали на {y}"),
        Message::Move { x, y } => format!("в ({x}, {y})"),
        Message::Write(text) if text.is_empty() => "пустой текст".to_string(),
        Message::Write(text) => format!("текст: {text}"),
        Message::ChangeColor(Color::Rgb(r, g, b)) => format!("RGB {r},{g},{b}"),
        // .. пропускает остальные поля HSV
        Message::ChangeColor(Color::Hsv(h, ..)) => format!("HSV с тоном {h}"),
    }
}

fn main() {
    let (a, b, _) = (1, 2, 3);
    let Point { x, y } = Point { x: 0, y: 7 };
    println!("{a} {b} {x} {y}");

    let n = 5;
    match n {
        1 | 2 => println!("один или два"),
        3..=9 => println!("от трёх до девяти"),
        _ => println!("что-то другое"),
    }

    match 6 {
        id @ 3..=7 => println!("id в диапазоне: {id}"),
        id => println!("другой id: {id}"),
    }

    let mut stack = vec![1, 2, 3];
    while let Some(top) = stack.pop() {
        print!("{top} ");
    }
    println!();

    let input = Some("42");
    let Some(raw) = input else {
        return; // ранняя ветка, если паттерн не подошёл
    };
    println!("{}", raw.parse::<i32>().unwrap() + 1);

    let pairs = vec![(1, 2), (3, 4)];
    let sums: Vec<i32> = pairs.iter().map(|&(a, b)| a + b).collect();
    println!("{sums:?}");

    println!("{}", describe(Message::Move { x: 0, y: 3 }));
    println!("{}", describe(Message::ChangeColor(Color::Hsv(10, 20, 30))));
    println!("{}", describe(Message::Write(String::new())));
    println!("{}", describe(Message::Quit));
    println!("{}", describe(Message::Write("привет".into())));
    println!("{}", describe(Message::ChangeColor(Color::Rgb(1, 2, 3))));
    println!("{}", describe(Message::Move { x: 1, y: 2 }));
}

Важно запомнить

Паттерны используются в let, match, if let, while let, for и в параметрах функций — это единый язык разбора значений.

match обязан быть исчерпывающим; ветка _ ловит все оставшиеся случаи, которые вы не разобрали явно.

Конструкции let, for и параметры функций принимают только неопровержимые паттерны; опровержимые используют в if let, while let и let else.

Оператор .. пропускает остальные части значения, _ игнорирует одно место, а @ позволяет проверить условие и сохранить значение в переменную.

Сторожевое условие if уточняет ветку, но не участвует в проверке исчерпываемости — запасной случай всё равно нужен.

let ... else удобен для раннего выхода, когда значение не подходит под ожидаемую форму.

Урок 14 · Часть II. Углублённый Rust

Продвинутые возможности

Источник: ch20-00-advanced-features.html

Кратко

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

Основное

Небезопасный Rust

Блок unsafe { ... } даёт пять дополнительных возможностей («суперсил»):

  1. Разыменование сырых указателей const T и mut T.
  2. Вызов небезопасных функций и методов.
  3. Доступ к изменяемым статическим переменным (static mut) и их изменение.
  4. Реализация небезопасных трейтов (unsafe trait, unsafe impl).
  5. Доступ к полям union.

unsafe не отключает borrow checker целиком, а лишь снимает ряд проверок: ответственность за корректность переходит к программисту. Ошибка внутри unsafe может дать неопределённое поведение — порчу памяти, гонки, «призрачные» баги. Хорошая практика Книги — оборачивать небезопасный код в безопасную абстракцию (как split_at_mut): снаружи обычный безопасный API, внутри маленький проверенный unsafe.

Для вызова кода на других языках используется extern "C" (и другие ABI). Сырые указатели можно создавать в безопасном коде; их разыменование небезопасно. Они могут быть null, не гарантируют валидность и выравнивание и не освобождаются автоматически — это «голый» адрес, без страховки умного указателя.

Продвинутые трейты

  • Ассоциированные типы: type Item; — один выбор типа на реализацию (как у Iterator). Обобщённый трейт Trait<T> допускает несколько реализаций для одного типа с разными T; ассоциированный тип фиксирует «этот Iterator всегда выдаёт вот такие Item». Так сигнатуры проще читать и выводить.
  • Параметры обобщений по умолчанию: trait Add<Rhs = Self>; применяются для перегрузки операторов (impl Add for Point). По умолчанию правый операнд того же типа, что и левый, но можно сложить и с другим типом, указав Rhs явно. Без значения по умолчанию писать Add<Self> пришлось бы чаще.
  • Полный синтаксис вызова: при совпадении имён методов — Pilot::fly(&p) или <Dog as Animal>::baby_name() для ассоциированных функций без self. Когда одно имя есть и у типа, и у трейта, компилятор не угадает сам — уточняете путь вызова.
  • Супертрейты: trait OutlinePrint: fmt::Display требует, чтобы тип тоже реализовывал Display. Внутри методов супертрейта можно опираться на методы Display. Это как сказать: «для этой фичи сначала должен быть базовый навык».
  • Паттерн newtype: обёртка-кортежная структура позволяет реализовать внешний трейт для внешнего типа (обход правила сироты): struct Wrapper(Vec<String>);. Без newtype нельзя написать impl Display for Vec<String> в своём крейте. Обёртка — новый тип с нулевой стоимостью в рантайме, но своими impl.

Продвинутые типы

  • Псевдонимы: type Kilometers = i32;, type Thunk = Box<dyn Fn() + Send + 'static>;. Новый тип не создаётся, только короткое имя. Kilometers и i32 взаимозаменяемы — путаницу единиц псевдоним не защитит (для защиты нужен newtype).
  • Тип ! (never): функция никогда не возвращает управление (panic!, continue, loop без break). Тип ! приводится к любому типу, поэтому допустим в ветке match рядом с обычными значениями: «эта ветка не даёт значения, потому что сюда выполнение не вернётся».
  • Типы динамического размера (DST): str, [T], dyn Trait. Их размер известен только в рантайме, поэтому на стеке «голыми» не живут — только за указателем (&str, Box<dyn Trait>). Трейт Sized неявно добавляется к обобщённым параметрам; ?Sized снимает требование, когда обобщение должно принимать и DST.

Функции и замыкания

  • Указатель на функцию fn(i32) -> i32 реализует Fn, FnMut, FnOnce, поэтому передаётся везде, где ожидается замыкание. Пример: .map(ToString::to_string), конструкторы перечислений как функции .map(Status::Value). У fn нет захваченного окружения — это просто адрес кода.
  • Замыкание с захватом нельзя вернуть как тип fn; используют impl Fn или Box<dyn Fn>. Тип fn слишком «худой» для замыкания с данными.

Макросы

  • Декларативные macro_rules! — сопоставление с образцом по синтаксису токенов: так устроен vec!. Макрос видит код как набор заготовок и генерирует другой код до обычной компиляции.
  • Процедурные — функции над потоком токенов; три вида: derive (#[derive(Name)]), атрибутные (#[route(GET, "/")]), функциональные (sql!(...)). Должны находиться в отдельном крейте с proc-macro = true.
  • Макросы позволяют принимать переменное число аргументов и писать DSL. Цена — сложнее читать ошибки и отлаживать развёртку, чем обычные функции. Книга советует не заменять макросами то, что ясно выражается функцией.

Примеры

use std::fmt;
use std::ops::Add;

// Небезопасный кусок спрятан за безопасной функцией
fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();
    let ptr = values.as_mut_ptr();
    assert!(mid <= len); // проверка до unsafe
    unsafe {
        (
            std::slice::from_raw_parts_mut(ptr, mid),
            std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

static mut COUNTER: u32 = 0;

fn add_to_count(inc: u32) {
    unsafe {
        COUNTER += inc;
    }
}

#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Output = Point;
    fn add(self, other: Point) -> Point {
        Point { x: self.x + other.x, y: self.y + other.y }
    }
}

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let s = self.to_string();
        let len = s.chars().count();
        println!("{}", "*".repeat(len + 4));
        println!("* {s} *");
        println!("{}", "*".repeat(len + 4));
    }
}

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

impl OutlinePrint for Point {}

// Newtype: свой Display для «почти Vec<String>»
struct Wrapper(Vec<String>);

impl fmt::Display for Wrapper {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

trait Animal {
    fn baby_name() -> String;
}
struct Dog;
impl Dog {
    fn baby_name() -> String {
        String::from("Шарик")
    }
}
impl Animal for Dog {
    fn baby_name() -> String {
        String::from("щенок")
    }
}

fn add_one(x: i32) -> i32 {
    x + 1
}

fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
    f(f(arg))
}

#[derive(Debug)]
enum Status {
    Value(u32),
}

macro_rules! my_vec {
    ($($x:expr),* $(,)?) => {{
        let mut v = Vec::new();
        $(v.push($x);)*
        v
    }};
}

fn main() {
    let mut arr = [1, 2, 3, 4, 5];
    let (left, right) = split_at_mut(&mut arr, 2);
    left[0] = 10;
    right[0] = 30;
    println!("{arr:?}");

    add_to_count(3);
    let count = unsafe { COUNTER };
    println!("COUNTER = {count}");

    let p = Point { x: 1, y: 0 } + Point { x: 2, y: 3 };
    p.outline_print();
    println!("{}", Wrapper(vec!["a".into(), "b".into()]));

    // Разные baby_name: уточняем путь
    println!("{} {}", Dog::baby_name(), <Dog as Animal>::baby_name());
    println!("{}", do_twice(add_one, 5));

    let strings: Vec<String> = vec![1, 2].iter().map(ToString::to_string).collect();
    let statuses: Vec<Status> = (0u32..3).map(Status::Value).collect();
    println!("{strings:?} {statuses:?}");

    let v = my_vec![1, 2, 3];
    println!("{v:?}");

    let mut num = 5;
    let r1 = &raw const num;
    let r2 = &raw mut num;
    unsafe {
        *r2 += 1;
        println!("r1 = {}", *r1);
    }
}

Важно запомнить

unsafe разрешает пять конкретных действий, но не выключает все проверки языка; минимизируйте небезопасные блоки и прячьте их за безопасным API.

Ассоциированные типы фиксируют один тип на реализацию; параметры обобщений по умолчанию (как Rhs = Self) упрощают перегрузку операторов.

Паттерн newtype обходит правило сироты и вводит новый тип без накладных расходов в рантайме.

type создаёт только псевдоним, а не новый тип; ! — тип для кода, который никогда не возвращает управление.

str и dyn Trait имеют динамический размер и используются только за указателем; ?Sized ослабляет неявное требование Sized у обобщений.

Тип fn реализует все трейты Fn*; замыкания с захватом возвращайте как impl Fn или Box<dyn Fn>.

Макросы бывают декларативные (macro_rules!) и процедурные (derive, атрибутные, функциональные) — их сила в генерации кода, а сложность в отладке.

Урок 15 · Часть II. Углублённый Rust

Итоговый проект: многопоточный веб-сервер (обзор)

Источник: ch21-00-final-project-a-web-server.html

Кратко

Финальный проект Книги — простой HTTP-сервер на стандартной библиотеке: слушает TCP-порт, отдаёт HTML-страницы и обрабатывает запросы параллельно с помощью пула потоков (ThreadPool). Это учебный «сводный экзамен»: в одном месте встречаются владение, трейты, замыкания, умные указатели и многопоточность. Сервер намеренно минимальный — чтобы было видно каждую деталь, а не спрятать её за фреймворком. Для реальных задач обычно берут готовые решения (tokio, axum и т. д.), но идеи пула, канала заданий и аккуратного Drop остаются теми же.

Основное

HTTP в двух словах

Запрос: Method Request-URI HTTP-Version CRLF headers CRLF message-body. Ответ: HTTP-Version Status-Code Reason-Phrase CRLF headers CRLF body. Пример минимального успеха: HTTP/1.1 200 OK\r\n\r\n плюс тело.

Сервер Книги разбирает в основном первую строку запроса (request line) и почти не занимается заголовками. Этого достаточно, чтобы понять поток «байты с сокета → решение → байты ответа». Если перепутать \r\n или забыть Content-Length, клиент может зависнуть или показать мусор — формат протокола жёсткий.

Шаги

  1. Приём соединений: TcpListener::bind("127.0.0.1:7878"); listener.incoming() отдаёт TcpStream. Пока никто не подключился, итератор ждёт. Каждый принятый поток — отдельное соединение с клиентом.
  2. Чтение запроса: BufReader::new(&stream).lines().next() — первая строка определяет маршрут. Буферизация удобна, чтобы читать построчно текст HTTP. Ошибки чтения в учебном коде часто просто unwrap — в бою их обрабатывают аккуратнее.
  3. Ответ: статус-строка, заголовок Content-Length, тело из hello.html / 404.html. Маршрутизация — match по строке запроса. Неизвестный путь → 404. Так вы видите простейший роутер без фреймворка.
  4. Проблема однопоточности: медленный запрос (/sleep) блокирует всех остальных. Один поток обслуживает соединения по очереди: пока «спит», другие клиенты ждут. Отсюда мотивация параллелизма.
  5. Пул потоков: заранее создаётся фиксированное число потоков-воркеров (защита от DoS в отличие от «поток на каждый запрос»). Неограниченный spawn при шквале соединений может исчерпать память и дескрипторы. Работа передаётся через канал mpsc, приёмник общий: Arc<Mutex<Receiver<Job>>> — несколько воркеров делят одну очередь.
  6. Типы: type Job = Box<dyn FnOnce() + Send + 'static>; ThreadPool::new(size) (паникует при size == 0), execute<F>(&self, f: F) where F: FnOnce() + Send + 'static. Задание — замыкание, упакованное в кучу, чтобы разные запросы имели один тип в канале.
  7. Корректное завершение: impl Drop for ThreadPool — закрыть отправителя (drop(self.sender.take())), затем join для каждого воркера (Option<JoinHandle<()>> и take()). Закрытие канала заставляет recv вернуть ошибку — сигнал воркерам выйти из цикла. Без этого потоки могли бы висеть вечно после уничтожения пула.

Почему такие ограничения у execute

  • FnOnce — запрос выполняется один раз: замыкание может потребить TcpStream и другие владеющие ресурсы. Fn/FnMut были бы слишком строги для типичного обработчика соединения.
  • Send — замыкание передаётся между потоками по каналу. Без Send компилятор запретил бы отправку задания воркеру.
  • 'static — неизвестно, сколько проработает поток; задание не должно ссылаться на данные, которые умрут раньше воркера. На практике это значит: либо владеющие данные внутри замыкания, либо 'static ссылки.

Если ослабить эти границы «на глаз», легко получить гонки или висячие ссылки — именно поэтому сигнатура такая строгая.

Мьютекс на приёмнике

Воркер должен получить задание, а затем освободить блокировку до выполнения работы. Конструкция let job = receiver.lock().unwrap().recv().unwrap(); создаёт временный гард, который живёт только до конца выражения recv — после присваивания в job мьютекс уже свободен, и другие воркеры могут брать следующие задания, пока первый выполняет текущее.

Напротив, while let Ok(job) = receiver.lock().unwrap().recv() удерживает гард на всё тело цикла: мьютекс не отпустят, пока идёт job(). Тогда очередь фактически обслуживается одним воркером — пул теряет смысл. Книга отдельно подчёркивает эту ловушку области видимости гарда.

Примеры

// src/main.rs
use std::{
    fs,
    io::{prelude::*, BufReader},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

use hello::ThreadPool;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    // take(2) в учебном примере — ограничить число соединений перед завершением
    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();
        pool.execute(|| handle_connection(stream));
    }
    println!("Завершение работы.");
}

fn handle_connection(mut stream: TcpStream) {
    let request_line = BufReader::new(&stream).lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5)); // имитация медленного запроса
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();
    let response = format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
    stream.write_all(response.as_bytes()).unwrap();
}
// src/lib.rs
use std::{
    sync::{mpsc, Arc, Mutex},
    thread,
};

type Job = Box<dyn FnOnce() + Send + 'static>;

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}

impl ThreadPool {
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();
        let receiver = Arc::new(Mutex::new(receiver));

        let workers = (0..size)
            .map(|id| Worker::new(id, Arc::clone(&receiver)))
            .collect();

        ThreadPool { workers, sender: Some(sender) }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        self.sender.as_ref().unwrap().send(Box::new(f)).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        // Закрываем канал: у воркеров recv() вернёт Err
        drop(self.sender.take());

        for worker in &mut self.workers {
            println!("Остановка воркера {}", worker.id);
            if let Some(thread) = worker.thread.take() {
                thread.join().unwrap();
            }
        }
    }
}

struct Worker {
    id: usize,
    thread: Option<thread::JoinHandle<()>>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || loop {
            // Гард мьютекса живёт только до конца recv — затем отпускается
            let message = receiver.lock().unwrap().recv();
            match message {
                Ok(job) => {
                    println!("Воркер {id} получил задание.");
                    job();
                }
                Err(_) => {
                    println!("Воркер {id} отключён.");
                    break;
                }
            }
        });

        Worker { id, thread: Some(thread) }
    }
}

Важно запомнить

Сервер на std строится так: TcpListener принимает соединения, TcpStream читает и пишет байты, HTTP-ответ собирается строками вручную.

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

Задания оформляют как Box<dyn FnOnce() + Send + 'static>, а общий приёмник канала делят через Arc<Mutex<Receiver<Job>>>.

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

Корректное завершение пула: закрыть отправитель канала и вызвать join для всех потоков, обычно в Drop.

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

Практика 3 · Часть II. Углублённый Rust

Практика: продвинутые темы

Задания для закрепления темы · решений нет

Темы: 06-01–06-06.

Задание 1. Рекурсивный список на Box

Сложность: лёгкое

Определите enum List { Cons(i32, Box<List>), Nil } и реализуйте fn sum(&self) -> i32 и fn len(&self) -> usize рекурсивно через match. Постройте список из трёх элементов и выведите результаты.

Задание 2. Паттерны в match

Сложность: лёгкое

Напишите fn classify(n: i32) -> &'static str, которая с помощью match возвращает: "отрицательное", "ноль", "малое" (1–9), "среднее" (10–99), "большое" (остальные положительные). Используйте диапазоны и guard. Затем напишите функцию, принимающую (i32, i32) и использующую @-привязку: x @ 1..=5 для первой координаты.

Задание 3. Общий счётчик в потоках

Сложность: среднее

Запустите 8 потоков, каждый добавляет по 1000 к общему счётчику Arc<Mutex<u64>>. Дождитесь завершения и убедитесь, что итог равен 8000. Затем перепишите вариант с каналом mpsc: каждый поток отправляет свою частичную сумму, главный поток суммирует результаты. Сравните подходы в комментарии.

Задание 4. Дерево с Weak

Сложность: среднее

Реализуйте struct TreeNode { value: i32, parent: RefCell<Weak<TreeNode>>, children: RefCell<Vec<Rc<TreeNode>>> } и функцию add_child(parent: &Rc<TreeNode>, value: i32) -> Rc<TreeNode>. Напишите метод path_to_root(&self) -> Vec<i32>, собирающий значения от узла к корню через upgrade(). Выведите Rc::strong_count и Rc::weak_count для корня и убедитесь, что утечек нет (после drop корня родитель листа upgrade() возвращает None).

Задание 5. Паттерн «состояние» на типах и трейт-объекты

Сложность: сложное

Реализуйте заказ с состояниями New → Paid → Shipped двумя способами:

  1. Через трейт-объект: Option<Box<dyn State>> с self: Box<Self>; метод status(&self) -> &'static str.
  2. Через типы: Order<New>, Order<Paid>, Order<Shipped> с PhantomData; методы pay(self) -> Order<Paid>, ship(self) -> Order<Shipped>.

Покажите, что в варианте 2 вызов ship на Order<New> не компилируется (добавьте закомментированную строку и пояснение). Дополнительно: Vec<Box<dyn Describe>> для вывода разных заказов.

Задание 6. Пул потоков и макрос

Сложность: сложное

  1. Реализуйте мини-пул потоков ThreadPool (new(size), execute<F: FnOnce() + Send + 'static>(&self, f: F), корректный Drop с join) по образцу главы 21.
  2. Напишите декларативный макрос hashmap!{ "a" => 1, "b" => 2 }, возвращающий HashMap, и макрос timed!(expr), печатающий время выполнения выражения (std::time::Instant).
  3. Через пул параллельно вычислите факториалы чисел 1..=20 и соберите результаты через канал в Vec<(u64, u64)>, отсортируйте и выведите. Проверьте, что пул завершается без зависаний.

Дополнительно: оберните разыменование сырого указателя в безопасную функцию fn swap_halves(slice: &mut [i32]), меняющую местами половины среза с помощью unsafe и split_at_mut-подобной логики, и сопроводите её комментарием // SAFETY:.

↑