- Часть 1: Инструментарий и основы «Expression-based» дизайна
- 1.1. Инструментарий: Rustup и Cargo
- 1.2. Структура проекта и Cargo.toml
- 1.3. Переменные, Константы и Shadowing
- 1.4. Скалярные и составные типы данных
- 1.5. Управляющие конструкции как выражения
- 1.6. Функции и Методы: Основы сигнатур
- 1.7. Pattern Matching: Самый мощный инструмент Rust
- 1.8. Expression-based дизайн на практике
- Итог Части 1:
- Часть 1.5: Анатомия компиляции и сборки
- Часть 2: Глубокое управление памятью (Ownership & Lifetimes)
- Часть 3: Типизация и Алгебраические типы (Enums)
- Часть 4: Дизайн абстракций (Traits & Generics)
- Часть 5: Обработка ошибок и обеспечение качества (QA)
- Часть 6: Коллекции и Memory Layout
- Часть 7: Функциональный Rust: Итераторы и Замыкания
- Часть 8: Параллелизм и Асинхронная архитектура
- Часть 9: Метапрограммирование (Макросы)
- Часть 10: Системный уровень (Unsafe & Performance)
- Часть 11: Архитектура и Проектирование систем
Прежде чем писать код, нужно понять, как живет экосистема Rust. В отличие от многих языков, где инструменты разрознены, в Rust всё стандартизировано.
- Rustup: Это установщик и менеджер версий. Rust обновляется каждые 6 недель.
rustupпозволяет переключаться между версиями (stable, beta, nightly) и добавлять таргеты для кросс-компиляции. - Cargo: Это «швейцарский нож». Он одновременно является менеджером пакетов (как npm или pip), системой сборки (как Maven/Gradle) и инструментом для запуска тестов и документации.
- Clippy и Rustfmt: *
Rustfmtформатирует код согласно официальному гайду. В Rust не спорят о том, где ставить скобку — это решает инструмент.Clippy— это мощный статический анализатор (линт), который ловит не только ошибки, но и «неидиоматичный» код, предлагая лучшие решения.
Когда вы создаете проект командой cargo new my_project, Cargo создает структуру:
src/main.rs— точка входа для бинарного приложения.Cargo.toml— манифест проекта.
Пример Cargo.toml:
[package]
name = "my_project"
version = "0.1.0"
edition = "2021" # Текущая современная эпоха Rust
[dependencies]
# Здесь указываются библиотеки (crates)
В Rust переменные ведут себя иначе, чем в большинстве языков. Главный принцип — безопасность через ограничения.
В Rust переменные по умолчанию неизменяемы. Если вы попытаетесь изменить x в примере ниже, компилятор выдаст ошибку.
fn main() {
let x = 5;
// x = 6; // ОШИБКА: cannot assign twice to immutable variable
let mut y = 10; // Ключевое слово mut разрешает изменение
y = 15; // Теперь это валидно
println!("x: {}, y: {}", x, y);
}
Rust позволяет объявить новую переменную с тем же именем, что и у предыдущей. Это называется "затенением". Предыдущая переменная не меняется, она «перекрывается» новой.
fn main() {
let space = " "; // Тип &str (строковый слайс)
let space = space.len(); // Теперь space — это тип usize (число)
// Это полезно, когда нужно трансформировать данные,
// не создавая мусорные имена типа space_str, space_len.
println!("Количество пробелов: {}", space);
}
const — это не то же самое, что неизменяемая переменная (let).
- Они всегда неизменяемы (нельзя добавить
mut). - Тип обязательно должен быть указан.
- Значение должно быть известно на этапе компиляции.
- Константы встраиваются (inlined) в место вызова.
const MAX_POINTS: u32 = 100_000;
Rust — статически типизированный язык. Компилятор должен знать типы всех переменных.
- Целые числа: * Знаковые:
i8,i16,i32,i64,i128,isize.- Беззнаковые:
u8,u16,u32,u64,u128,usize. isize/usizeзависят от архитектуры компьютера (32 или 64 бита) и используются в основном для индексации массивов.
- Беззнаковые:
- Числа с плавающей точкой:
f32иf64(по умолчаниюf64). - Логический тип:
bool(true/false). - Символьный тип:
char(4 байта, представляет собой Unicode Scalar Value).
- Кортежи (Tuples): Группируют значения разных типов. Имеют фиксированную длину.
let tup: (i32, f64, u8) = (500, 6.4, 1); let (x, y, z) = tup; // Деструктуризация let five_hundred = tup.0; // Доступ по индексу - Массивы (Arrays): Группируют значения одного типа. Имеют фиксированную длину и располагаются на стеке.
Важно: попытка доступа к индексу вне границ массива вызоветlet a: [i32; 5] = [1, 2, 3, 4, 5]; let first = a[0];panic(аварийное завершение), Rust гарантирует безопасность доступа к памяти.
Это одна из самых сильных сторон Rust. Почти всё в Rust является выражением (expression), то есть возвращает значение.
Вам не нужен тернарный оператор condition ? a : b, потому что if сам по себе возвращает результат.
fn main() {
let condition = true;
// Блоки в if должны возвращать типы одного вида
let number = if condition { 5 } else { 6 };
println!("Значение: {}", number);
}
- loop: Бесконечный цикл. Может возвращать значение через
break.let mut counter = 0; let result = loop { counter += 1; if counter == 10 { break counter * 2; // Возвращаем значение из цикла } }; - while: Классический цикл с условием.
- for: Самый идиоматичный способ обхода коллекций.
let a = [10, 20, 30]; for element in a.iter() { println!("Элемент: {}", element); }
Функции в Rust объявляются через ключевое слово fn. В отличие от переменных, в сигнатурах функций типы аргументов и возвращаемого значения обязательны.
fn main() {
let sum = add_numbers(5, 10);
println!("Результат: {}", sum);
}
// Аргументы: x и y с явными типами i32
// Возвращаемое значение: после стрелки ->
fn add_numbers(x: i32, y: i32) -> i32 {
x + y // Обратите внимание: НЕТ точки с запятой!
}
Важное правило: В Rust последнее выражение в блоке является возвращаемым значением. Если вы поставите
;, выражение превратится в инструкцию (statement), которая ничего не возвращает (точнее, возвращает пустой кортеж()).
Методы — это функции, привязанные к конкретному контексту структуры (struct) или перечисления (enum). Они всегда определяются внутри блока impl (implementation).
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
// Ассоциированная функция (аналог статического метода)
// Вызывается как Rectangle::new(10, 20)
fn new(w: u32, h: u32) -> Self {
Self { width: w, height: h }
}
// Метод экземпляра. Первый аргумент всегда self.
// &self означает заимствование (чтение), &mut self — изменение.
fn area(&self) -> u32 {
self.width * self.height
}
}
Паттерн-матчинг в Rust — это не просто switch из других языков. Это механизм деструктуризации и проверки данных, который гарантирует, что вы обработали все возможные варианты.
match заставляет вас быть дисциплинированным. Если вы не обработаете какой-то случай, код не скомпилируется.
enum Coin {
Penny,
Nickel,
Dime,
Quarter(String), // Вариант может нести данные!
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => {
println!("Счастливая монетка!");
1
},
Coin::Nickel => 5,
Coin::Dime => 10,
// Использование данных внутри варианта
Coin::Quarter(state) => {
println!("Квотер из штата: {}", state);
25
},
}
}
Вы можете добавить дополнительные условия к веткам match прямо "на лету".
let num = Some(4);
match num {
Some(x) if x < 5 => println!("Меньше пяти: {}", x),
Some(x) => println!("Число: {}", x),
None => (),
}
Иногда match слишком громоздок, если вас интересует только один конкретный случай. Для этого есть "синтаксический сахар" if let.
let config_max = Some(3u8);
// Вместо match с обработкой None: _ => ()
if let Some(max) = config_max {
println!("Максимум установлен: {}", max);
}
Поскольку функции, блоки if, match и даже loop являются выражениями, архитектура кода в Rust часто выглядит как "сборка конвейера". Мы не меняем переменные в цикле, а получаем результат трансформации.
Пример архитектурного паттерна "Match как выражение":
let message = match status_code {
200 => "ОК",
404 => "Не найдено",
500..=599 => "Ошибка сервера", // Диапазон значений
_ => "Неизвестный статус", // Default случай (обязателен)
};
Мы разобрали:
- Как настраивать окружение.
- Почему переменные неизменяемы по умолчанию и как работает Shadowing.
- Как типы данных влияют на память (стек для массивов).
- Как писать функции и методы.
- Почему
match— это основа надежности.
Процесс работы компилятора rustc — это не прямая линия «текст -> бинарник», а серия глубоких трансформаций. Каждая стадия добавляет новый уровень анализа.
- AST (Abstract Syntax Tree): Компилятор читает ваш текст и строит дерево. На этом этапе Rust проверяет только грамматику (забытые скобки, синтаксис). Важно: именно здесь работают макросы (
macro_rules!). Они берут одни куски дерева и заменяют их на другие. - HIR (High-level Intermediate Representation): После раскрытия макросов AST превращается в HIR. Здесь Rust начинает «понимать» типы. Происходит Type Inference (вывод типов) — компилятор догадывается, что ваш
let x = 5— этоi32. - MIR (Mid-level Intermediate Representation): Это «сердце» Rust. В MIR код представлен в виде графа потока управления (Control Flow Graph).
- Здесь живет Borrow Checker. Он не анализирует ваш текстовый код, он анализирует MIR.
- В MIR все циклы
forпревращаются в простые переходы, а сложные выражения разбиваются на элементарные шаги.
- LLVM IR (Intermediate Representation): Rust передает оптимизированный MIR в LLVM — мощный промышленный бэкенд (который также используют C++ и Swift). LLVM делает финальную магию: инлайнинг функций, удаление мертвого кода и векторизацию.
В Rust дженерики (обобщения) работают через мономорфизацию.
Теория: Когда вы пишете функцию fn process<T>(item: T), компилятор не создает одну универсальную функцию. Вместо этого, для каждого типа T, с которым вы вызвали эту функцию (например, i32 и String), Rust сгенерирует отдельную копию функции в машинном коде.
- Плюс: Максимальная производительность (нет накладных расходов в рантайме, всё статически известно).
- Минус: Code Bloat (раздувание бинарника) и долгое время компиляции, если дженериков очень много.
Многие жалуются на долгую сборку Rust. Архитектору важно понимать, почему это происходит:
- Крейт (Crate) — единица сборки: Rust компилирует код покрейтово. Если вы изменили одну строку в Крейте А, от которого зависят Крейты Б и В, Rust придется проверять связи.
- Инкрементальная сборка:
rustcстарается переиспользовать результаты прошлых запусков, кэшируя стадии (HIR, MIR). - Сложность анализа: Borrow Checker и LLVM-оптимизации требуют много вычислительных ресурсов.
Совет архитектора: Чтобы ускорить сборку больших систем, их нужно разбивать на независимые крейты в рамках Cargo Workspace. Это позволяет Cargo компилировать части проекта параллельно.
Рассмотрим простой код:
fn main() {
let mut x = 5;
let y = &x;
// x = 10; // Если раскомментировать, Borrow Checker выдаст ошибку
println!("{}", y);
}
Что происходит в MIR (упрощенно):
- Создается ячейка памяти
_1(этоx). - В
_1записывается значение5. - Создается ссылка
_2, указывающая на_1. Borrow Checker помечает: "Ячейка_1сейчас заимствована, ее нельзя изменять, пока жива ссылка_2". - Вызов функции печати использует
_2. - Конец области видимости:
_2больше не нужен. Ограничение с_1снимается.
Если вы хотите увидеть, во что превращается ваш код, используйте эти команды:
cargo expand— посмотреть код после раскрытия всех макросов (нужно установить:cargo install cargo-expand).rustc --emit mir src/main.rs— посмотреть сгенерированный MIR.- Compiler Explorer (godbolt.org) — лучший способ увидеть ассемблерный код, который выдает LLVM.
В языках с GC память очищается рантаймом. В C/C++ это делает программист вручную. В Rust памятью управляет система владения на этапе компиляции.
- У каждого значения в Rust есть переменная, которая называется его владельцем.
- В каждый момент времени у значения может быть только один владелец.
- Когда владелец выходит из области видимости, значение будет немедленно удалено.
Когда вы присваиваете одну переменную другой, Rust по умолчанию перемещает владение, а не копирует данные.
fn main() {
let s1 = String::from("hello"); // s1 владеет строкой в куче
let s2 = s1; // Владение ПЕРЕМЕСТИЛОСЬ в s2. s1 теперь невалидна.
// println!("{}", s1); // ОШИБКА: value borrowed here after move
println!("{}", s2); // Работает
} // Здесь s2 выходит из области видимости и память очищается (вызов Drop)
Типы, которые хранятся целиком на стеке (числа, bool, char), реализуют трейт Copy. Они копируются, а не перемещаются.
let x = 5;
let y = x; // x все еще валиден, так как это простое копирование 4 байт на стеке
Чтобы не передавать владение (и не терять доступ к переменной), мы используем ссылки. Это называется заимствованием.
- Вы можете иметь либо одну изменяемую ссылку (
&mut T), - Либо любое количество неизменяемых ссылок (
&T). - Но нельзя иметь их одновременно.
fn main() {
let mut s = String::from("hello");
let r1 = &s; // Неизменяемая ссылка
let r2 = &s; // Еще одна неизменяемая
println!("{} и {}", r1, r2); // Ок
let r3 = &mut s; // Изменяемая ссылка.
// println!("{}", r1); // ОШИБКА: r1 больше нельзя использовать, так как есть r3
r3.push_str(", world");
}
Почему это важно? Это предотвращает гонки данных (Data Races) еще на этапе компиляции. Если никто не может менять данные, пока вы их читаете, ваш код потокобезопасен.
Лайфтаймы — это способ компилятора убедиться, что все ссылки валидны и не указывают на очищенную память (Dangling pointers).
Обычно Rust выводит лайфтаймы сам (Elision rules), но иногда ему нужна помощь.
Они не меняют время жизни, они лишь описывают связь между временами жизни разных ссылок.
// 'a — это название лайфтайма.
// Мы говорим: возвращаемая ссылка будет жить столько же,
// сколько живет МЕНЬШАЯ из ссылок x или y.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
Когда вы передаете &mut T в функцию, Rust делает «reborrow». Он на время «замораживает» исходную ссылку и создает новую. Это позволяет использовать изменяемую ссылку несколько раз подряд, не теряя её.
Это правила, по которым один лайфтайм может считаться подтипом другого.
- Ковариантность:
&'static strможно передать туда, где ожидается&'a str(длинное время жизни заменяет короткое). - Инвариантность:
&mut Tинвариантен поT. Если вы ожидаете&mut &'a str, вы не можете передать&mut &'static str. Почему? Потому что если функция запишет внутрь ссылку с коротким лайфтаймом, ваша 'static переменная «протухнет».
Rust должен гарантировать, что когда структура удаляется (вызывается Drop), её поля всё еще валидны. Это критично, если в деструкторе вы обращаетесь к заимствованным данным.
struct Parser<'a> {
context: &'a str, // Парсер не владеет строкой, он её заимствует
}
impl<'a> Parser<'a> {
fn parse(&self) -> Result<(), &'static str> {
// Мы возвращаем ошибку со временем жизни 'static,
// так как это строковая константа в бинарнике.
Err("Unexpected token")
}
}
В этом примере структура Parser жестко привязана к лайфтайму входных данных. Она не сможет пережить строку context. Это гарантирует, что мы никогда не попытаемся парсить «мусор» из памяти.
Rust использует систему ADT (Algebraic Data Types). Это значит, что ваши типы данных — это не просто наборы полей, а математически строгие конструкции.
Структуры в Rust позволяют объединять данные. Компилятор гарантирует, что порядок полей в памяти будет оптимизирован для уменьшения пустого пространства (padding), если не указано иное.
- Классические структуры: Имеют именованные поля.
struct User { id: u64, active: bool, } - Кортежные структуры (Tuple Structs): Поля не имеют имен, доступ по индексу. Полезны для паттерна Newtype.
struct Color(i32, i32, i32); let black = Color(0, 0, 0); - Unit-структуры: Не занимают места в памяти (ZST — Zero Sized Types). Используются как маркеры в системе типов или для реализации трейтов.
struct Marker;
Перечисления в Rust — это Sum Types (типы-суммы). В отличие от Java или C++, варианты перечисления в Rust могут нести в себе данные разных типов.
enum Message {
Quit, // Нет данных (Unit)
Move { x: i32, y: i32 }, // Анонимная структура
Write(String), // Кортежный вариант
ChangeColor(i32, i32, i32), // Несколько значений
}
Для реализации enum в памяти Rust использует концепцию Tag (дискриминант). Это небольшое целое число, которое говорит коду, какой именно вариант сейчас активен.
- Размер enum равен размеру самого большого варианта + размер тега (обычно 1-8 байт) + выравнивание (padding).
- Niche Optimization: Если Rust видит, что внутри варианта есть тип, который не может быть нулевым (например,
&TилиNonZeroU32), он может использовать «нулевое» значение этого типа как тег. Именно поэтомуOption<&T>занимает ровно столько же места, сколько и обычный указатель&T. Это называется Zero-cost Option.
В Rust нет ключевого слова null. Вместо него используются два фундаментальных enum.
Заменяет собой риск получить NullPointerException.
enum Option<T> {
Some(T),
None,
}
Чтобы достать значение, вы обязаны обработать случай None через match или if let.
Используется для функций, которые могут завершиться ошибкой.
enum Result<T, E> {
Ok(T),
Err(E),
}
Паттерн-матчинг — это основной способ «распаковки» enum.
fn process_message(msg: Message) {
match msg {
Message::Quit => println!("Выход"),
Message::Move { x, y } => println!("Смещение в {}, {}", x, y),
// Паттерн @ позволяет одновременно проверить условие и создать переменную
Message::Write(text) if text.len() > 10 => {
println!("Длинное сообщение: {}", text);
},
Message::Write(text) => println!("Текст: {}", text),
_ => (), // Игнорировать всё остальное
}
}
..— игнорировать оставшиеся поля структуры._— «поглотить» любое значение без привязки к имени.|— выбор одного из нескольких вариантов (Coin::Penny | Coin::Nickel).
Рассмотрим пример оптимизации памяти через enum:
use std::mem::size_of;
enum Light {
On,
Off,
}
fn main() {
// Размер будет 1 байт (тег)
println!("Размер Light: {}", size_of::<Light>());
// Благодаря Niche Optimization размер Option<Light> тоже будет 1 байт!
// Rust использует неиспользуемые значения (числа больше 1) для представления None.
println!("Размер Option<Light>: {}", size_of::<Option<Light>>());
}
- Structs объединяют данные (Product Types).
- Enums позволяют выбирать между данными (Sum Types).
- Layout оптимизируется компилятором автоматически (Niche Optimization).
- Безопасность достигается за счет отсутствия
nullи обязательной обработки всех вариантовmatch.
Эта часть разделяет просто написание кода и проектирование гибких систем.
Дженерики позволяют писать код, работающий с множеством типов. Как мы помним из Части 1.5, Rust использует мономорфизацию: для каждого конкретного типа генерируется своя копия кода.
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T { &self.x }
}
Трейт — это набор методов, который тип может реализовать. Это контракт.
trait Summary {
fn summarize(&self) -> String;
// Метод по умолчанию
fn author(&self) -> String {
String::from("Unknown")
}
}
Архитектору важно знать это ограничение: вы можете реализовать трейт для типа только если либо трейт, либо тип определены в вашем крейте. Вы не можете реализовать Display (из стандартной библиотеки) для Vec (тоже из стандартной библиотеки). Это защищает экосистему от конфликтов реализаций.
Когда вы используете дженерики с ограничениями (Trait Bounds), компилятор знает конкретный тип во время сборки.
// Т: Summary — это Trait Bound
fn notify<T: Summary>(item: &T) {
println!("Новости: {}", item.summarize());
}
- Плюс: Нет накладных расходов в рантайме. Компилятор может заинлайнить код.
- Минус: Код функции дублируется для каждого типа (Code Bloat).
Иногда тип неизвестен до момента выполнения (например, массив разных объектов). Для этого используются Трейт-объекты.
// dyn Summary — это трейт-объект. Он ВСЕГДА под указателем (& или Box)
fn notify_dyn(item: &dyn Summary) {
println!("Новости: {}", item.summarize());
}
В отличие от обычного указателя, &dyn Trait — это толстый указатель (Fat Pointer). Он состоит из двух адресов:
- Адрес самих данных (экземпляра типа).
- Адрес Vtable (виртуальной таблицы методов).
Не любой трейт можно превратить в dyn Trait. Трейт должен быть Object Safe. Основные ограничения:
- Методы не должны возвращать
Self. - Методы не должны использовать дженерики. Почему? Потому что в Vtable должна быть фиксированная запись, а дженерик порождает бесконечное количество реализаций.
- Supertraits: Требование реализации одного трейта для другого (
trait Person: Display {}). - Blanket Implementations: Реализация трейта для всех типов, которые уже реализуют другой трейт (
impl<T: Display> ToString for T). - GATs (Generic Associated Types): Позволяют ассоциированным типам самим иметь дженерики. Это критично для библиотек, работающих с памятью или сложными контейнерами.
Выбор между статикой и динамикой — ключевое решение архитектора.
// Статика: компилятор создаст копии для каждого типа.
// Быстро в рантайме, долго компилируется.
fn process_static<T: Summary>(items: Vec<T>) { /* ... */ }
// Динамика: одна функция для всех типов.
// Чуть медленнее (прыжок по Vtable), но экономит размер бинарника.
fn process_dynamic(items: Vec<Box<dyn Summary>>) { /* ... */ }
- Generics дают гибкость без потери скорости (мономорфизация).
- Traits — единственный способ полиморфизма в Rust.
- Vtable — механизм работы динамических объектов через толстые указатели.
- Object Safety — строгое ограничение на использование
dyn.
В Rust нет исключений (exceptions), которые могут «выстрелить» в любой момент и размотать стек до самого верха. Ошибки здесь — это просто значения.
Архитектурно ошибки в Rust делятся на два типа:
- Неисправимые (Unrecoverable):
panic!. Используется при нарушении инвариантов программы (например, обращение по индексу вне границ). - Исправимые (Recoverable):
Result<T, E>. Используется для всего остального.
Архитектор должен разделять эти уровни:
- Domain Errors (
thiserror): Внутри библиотеки или модуля мы определяем строгие перечисления ошибок. Библиотекаthiserrorпомогает делать это с минимальным шаблонным кодом. - Application Errors (
anyhow): На верхнем уровне (вmainили обработчиках запросов) нам часто не важен конкретный тип ошибки, важно лишь получить сообщение и контекст. Здесь правитanyhow.
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error("пользователь с id {0} не найден")]
NotFound(u64),
#[error("ошибка сети: {0}")]
Network(#[from] std::io::Error), // Автоматическая конвертация
}
fn fetch_user(id: u64) -> Result<String, DatabaseError> {
// Оператор '?' автоматически конвертирует и пробрасывает ошибку выше
let connection = std::net::TcpStream::connect("127.0.0.1:8080")?;
Err(DatabaseError::NotFound(id))
}
Хотя паника — это крайняя мера, архитектор должен знать о Panic Boundaries.
catch_unwind: Позволяет перехватить панику и превратить её вResult. Это критично для серверов (чтобы один упавший поток не положил всё приложение) и FFI (нельзя позволять панике Rust «улетать» в код на C).- UnwindSafe: Трейт-маркер, гарантирующий, что данные не остались в промежуточном (сломанном) состоянии после паники.
Rust — один из немногих языков, где инструменты тестирования встроены в ядро.
- Unit-тесты: Живут в том же файле, что и код (в модуле
mod tests). Имеют доступ к приватным полям. - Integration-тесты: Живут в папке
tests/. Видят ваш проект как внешнюю библиотеку (только публичное API). - Doc-тесты: Примеры кода в комментариях
///, которые компилятор проверяет на актуальность. Если пример в документации устарел и не компилируется — билд упадет.
- Mocking: Поскольку в Rust нет динамической подмены объектов в рантайме, DI (Dependency Injection) реализуется через трейты и дженерики.
- Property-based Testing (
proptest): Вместо того чтобы придумывать входные данные вручную, вы описываете стратегию (например, «любая строка от 0 до 100 символов»), и инструмент прогоняет тысячи случайных тестов. - Fuzzing (
cargo-fuzz): Инструмент скармливает вашей программе случайный мусор в огромных объемах, пытаясь найтиpanicили утечку памяти. Обязательно для парсеров и сетевых протоколов.
Для архитектора CI — это расширение компилятора.
cargo-audit: Проверяет ваши зависимости на наличие известных уязвимостей в базе данных RustSec.cargo-deny: Позволяет запретить использование определенных лицензий (например, GPL в коммерческом проекте) или дублирующихся зависимостей.cargo-tarpaulin: Генерирует отчет о покрытии кода тестами.
Организация цепочки ошибок с контекстом:
use anyhow::{Context, Result};
fn process_data() -> Result<()> {
// .context() добавляет человекочитаемое описание к низкоуровневой ошибке
let data = std::fs::read_to_string("config.toml")
.context("Не удалось прочитать файл конфигурации приложения")?;
Ok(())
}
fn main() {
if let Err(e) = process_data() {
// Печать всей цепочки причин: "Ошибка приложения" -> "Не удалось прочитать..." -> "Permission denied"
eprintln!("Ошибка: {:?}", e);
}
}
- Типизация ошибок через
Resultделает их частью контракта функции. thiserrorдля библиотек,anyhowдля приложений.- Тестирование в Rust многослойное: от юнит-тестов до фаззинга.
- CI должен включать аудит зависимостей и проверку лицензий.
В Rust нет "магических" коллекций. Все они — это структуры, использующие unsafe под капотом для прямого управления памятью, но предоставляющие безопасный API.
Вектор — это три слова (3 * 8 байт на 64-битной системе): указатель на данные в куче, текущая длина (len) и вместимость (capacity).
-
Стратегия роста: Когда
len == capacity, вектор переаллоцирует память (обычно удваивая её). Это амортизированная сложность$O\left(1\right)$ . -
Архитекторский совет: Если вы заранее знаете размер данных, всегда используйте
Vec::with_capacity(n), чтобы избежать лишних аллокаций и копирований.
Rust использует алгоритм SipHash, который устойчив к атакам типа "Hash DoS" (когда злоумышленник подбирает ключи так, чтобы возникло много коллизий).
- Entry API: Это самый эффективный способ работы с мапой. Он позволяет найти место в памяти один раз и либо вставить значение, либо обновить существующее.
// Вместо двух поисков (contains + insert), делаем один
map.entry(key).or_insert(42);
В отличие от многих языков, где по умолчанию используется красно-черное дерево, в Rust это B-дерево.
- Почему B-дерево? Оно хранит несколько элементов в одном узле (ноде). Это делает его невероятно кеш-локальным. Процессор считывает ноду целиком в кэш-линию, что на современных CPU работает быстрее, чем прыжки по указателям в обычном бинарном дереве.
Процессоры читают память не побайтово, а словами (обычно по 8 байт). Поэтому данные должны быть выровнены (aligned).
Если вы создадите структуру:
struct BadLayout {
a: u8, // 1 байт
b: u64, // 8 байт
c: u8, // 1 байт
}
Rust добавит "пустоту" (padding), чтобы b начиналось с адреса, кратного 8. Без оптимизации такая структура заняла бы 24 байта вместо реальных 10.
Оптимизация компилятора: Rust по умолчанию имеет право переставлять поля в памяти (repr(Rust)), чтобы минимизировать padding. Чтобы запретить это (например, для взаимодействия с C), используется #[repr(C)].
В Rust есть типы, размер которых неизвестен во время компиляции, например [u8] (слайс) или dyn Trait. Их нельзя хранить на стеке напрямую.
Для работы с ними используются Толстые указатели (Fat Pointers). Они занимают 16 байт вместо 8:
- Указатель на данные.
- Дополнительная информация: длина (для слайсов) или указатель на vtable (для трейт-объектов).
Архитектор часто сталкивается с ситуацией, когда данных обычно мало (помещаются на стек), но иногда их много.
- SmallVec / TinyVec: Библиотеки, позволяющие хранить до N элементов на стеке, и переходить в кучу только при превышении этого лимита. Это критично для горячих путей в коде.
Рассмотрим, как размер структуры влияет на производительность:
use std::mem::{size_of, align_of};
struct Point {
x: f64, // 8 байт
y: f64, // 8 байт
is_visible: bool, // 1 байт
}
fn main() {
// Размер будет 24 байта из-за Padding в конце (выравнивание по 8 байт)
println!("Размер Point: {} байт", size_of::<Point>());
// Но мы можем упаковать данные, если это критично (но потеряем в скорости доступа)
// #[repr(packed)] struct PointPacked { ... }
}
- Vec — стандарт де-факто, управляйте его вместимостью.
- BTreeMap часто лучше HashMap, если данных много и важна локальность кэша.
- Alignment — компилятор Rust оптимизирует порядок полей, но padding всё равно может возникать.
- Fat Pointers — это то, как Rust реализует динамику без потери метаданных о размере.
В Rust функциональный подход часто оказывается быстрее, чем императивный (циклы for с индексами), потому что компилятор лучше оптимизирует проверки границ (bounds checks) внутри итераторов.
Итератор — это любой тип, реализующий трейт Iterator. В его основе лежит метод next(), который возвращает Option<Item>.
- Типы итерации:
iter()— заимствует элементы (&T).iter_mut()— заимствует элементы для изменения (&mut T).into_iter()— забирает владение элементами (T). Коллекция после этого становится недоступной.
Итераторы ленивы: они ничего не делают, пока вы не вызовете «потребитель».
- Адаптеры (ленивые):
map,filter,take,enumerate,flatten. Они просто строят план трансформации. - Потребители (запускают цикл):
collect,fold,sum,for_each,find.
Замыкания — это анонимные функции, которые могут захватывать переменные из окружения. В Rust они реализуются как анонимные структуры, хранящие захваченные данные.
Компилятор автоматически реализует один из трех трейтов для каждого замыкания в зависимости от того, что оно делает с окружением:
Fn: Захватывает значения по ссылке (&T). Можно вызывать многократно.FnMut: Захватывает значения по изменяемой ссылке (&mut T). Можно вызывать многократно, меняя состояние.FnOnce: Захватывает значения по значению (Move). Можно вызвать только один раз, так как оно «съедает» свои переменные.
fn main() {
let mut count = 0;
// Ключевое слово move заставляет замыкание забрать владение
// переменной count, а не просто заимствовать её.
let mut inc = move || {
count += 1;
println!("Счетчик: {}", count);
};
inc(); // FnMut
}
Рассмотрим цикл:
let buffer = [0; 100];
// Императивный подход
for i in 0..buffer.len() {
let x = buffer[i]; // На каждой итерации Rust проверяет, что i < 100 (Bounds Check)
}
// Функциональный подход
for x in buffer.iter() {
// Итератор знает границы заранее. Проверка границ делается один раз
// или вообще убирается компилятором (Loop Unrolling).
}
Архитектурно важный момент: как собрать Vec<T> из итератора, если каждый шаг может вернуть ошибку? В Rust есть элегантное решение: collect умеет превращать Iterator<Item = Result<T, E>> в Result<Vec<T>, E>.
let strings = vec!["1", "2", "три"];
let numbers: Result<Vec<i32>, _> = strings
.into_iter()
.map(|s| s.parse::<i32>())
.collect();
// Если хоть одно число не распарсится, мы получим Err на весь вектор.
// Это пример транзакционного подхода к данным.
Использование Lending Iterators (или Streaming Iterators) — это тема, где нужны GATs (из Части 4). Обычный итератор не может возвращать ссылку на данные, которыми он сам владеет, так как next() выдает элемент, который должен пережить итератор. Архитекторы используют специальные паттерны (например, пакет lending-iterator), если нужно обрабатывать данные «окнами» без лишних аллокаций.
- Ленивость позволяет строить сложные цепочки обработки без промежуточных коллекций в памяти.
- Замыкания типизируются на уровне трейтов (
Fn,FnMut,FnOnce), что гарантирует безопасность памяти при передаче логики между потоками. - Zero-cost — итераторы компилируются в код, эквивалентный или превосходящий по скорости ручные циклы на C.
Архитектурно многопоточность в Rust делится на два лагеря: Shared State (общее состояние) и Message Passing (передача сообщений).
Это фундамент безопасности. Эти трейты не имеют методов, они лишь сообщают компилятору свойства типов:
Send: Тип можно безопасно передать в другой поток (владение переносится).Sync: К типу можно безопасно обращаться из нескольких потоков одновременно по ссылке (&TявляетсяSend).
Большинство типов являются
Send + Sync, но, например,Rc(счетчик ссылок для одного потока) не являетсяSend, поэтому вы не сможете случайно передать его между потоками — код просто не скомпилируется.
Когда нескольким потокам нужно владеть одним объектом и менять его, используется связка Arc<Mutex<T>>.
Arc<T>(Atomic Reference Counter): Позволяет иметь несколько владельцев данных. Когда последнийArcудаляется, данные очищаются.Mutex<T>: Гарантирует эксклюзивный доступ. В Rust данные находятся внутри мьютекса. Вы не можете получить доступ кT, не захвативlock.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// Arc позволяет безопасно клонировать указатель для каждого потока
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
// lock() возвращает MutexGuard. Когда он выходит из области видимости,
// мьютекс автоматически разблокируется (RAII).
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles { handle.join().unwrap(); }
println!("Результат: {}", *counter.lock().unwrap());
}
Асинхронность в Rust — это кооперативная многозадачность. В отличие от потоков ОС, асинхронные задачи не имеют собственного стека и очень легки (можно запускать миллионы задач).
Future в Rust — это машина состояний. Она ничего не делает, пока её не опросит (poll) исполнитель (Executor).
pub trait Future {
type Output;
// Context содержит Waker, который сообщает исполнителю,
// что задачу пора снова опросить (например, данные пришли из сети).
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
Асинхронные функции создают структуры, которые могут содержать ссылки на самих себя. Если такую структуру переместить в памяти, ссылки станут невалидными. Pin гарантирует, что объект не будет перемещен, что критично для безопасности памяти в асинхронном коде.
В Rust нет встроенного рантайма для асинхронности. Вы выбираете библиотеку (обычно Tokio).
- Cancellation Safety: В Rust отмена задачи происходит просто путем удаления (drop)
Future. Архитектор должен следить, чтобы прерываниеFutureв любой точкеawaitне оставляло систему в промежуточном состоянии (например, полузаписанный файл). - Select!: Позволяет ждать завершения первой из нескольких задач.
tokio::select! { val = rx => println!("Получено: {:?}", val), _ = tokio::time::sleep(Duration::from_secs(1)) => println!("Таймаут"), }
На самом низком уровне Rust предоставляет атомарные типы (AtomicUsize, AtomicBool).
- Memory Ordering: Архитекторы используют
Relaxed,Acquire,Release, чтобы управлять тем, как изменения памяти видны другим ядрам процессора. Это позволяет писать сверхбыстрые lock-free алгоритмы.
- Send/Sync предотвращают гонки данных на этапе компиляции.
- Arc/Mutex — стандарт для общего состояния.
- Async/Await — это эффективная машина состояний без накладных расходов на потоки ОС.
- Pin — защита от перемещения самоссылающихся структур.
В Rust макросы работают на уровне токенов и AST (абстрактного синтаксического дерева), а не просто заменяют текст, как в C/C++. Это делает их типобезопасными и предсказуемыми.
Это макросы «по образцу» (macros by example). Они похожи на оператор match, но сопоставляют не значения, а фрагменты исходного кода.
macro_rules! say_hello {
// ($name:expr) — захватываем выражение и называем его $name
($name:expr) => {
println!("Привет, {}!", $name);
};
}
fn main() {
say_hello!("Rust-архитектор");
}
- Гигиена (Hygiene): Декларативные макросы в Rust гигиеничны. Это значит, что переменные, созданные внутри макроса, не будут конфликтовать с переменными в коде, где этот макрос вызван.
- Munchers: Продвинутый паттерн, где макрос вызывает сам себя рекурсивно, «откусывая» по одному токену за раз для парсинга сложных структур.
Это функции на языке Rust, которые принимают поток токенов на вход и возвращают поток токенов на выход. Они компилируются до основного кода и работают как плагины к компилятору.
Разделяются на три типа:
Самый частый вид. Позволяет автоматически реализовывать трейты.
#[derive(Serialize, Debug)] // Авто-генерация кода для отладки и сериализации
struct User { id: u64 }
Похожи на аннотации. Могут полностью переписать структуру или функцию, к которой применены.
#[tokio::main] // Превращает обычный main в асинхронный запуск рантайма
async fn main() { ... }
Выглядят как вызов функции, но работают с произвольным синтаксисом.
let query = sqlx::query!("SELECT * FROM users WHERE id = ?", id);
// Макрос проверит синтаксис SQL прямо во время компиляции Rust!
Для архитектора создание макроса — это ответственность за то, как другие разработчики будут видеть ошибки.
- Diagnostics (Span): Каждый токен в Rust несет в себе информацию о том, где он находится в файле (строка, колонка). При написании процедурных макросов важно сохранять эти «пролеты» (Spans). Если ваш макрос генерирует ошибку, компилятор должен подсветить не сам макрос, а то место в коде пользователя, которое вызвало проблему.
- Библиотеки
synиquote: *syn— парсит поток токенов Rust в удобные структуры данных (AST).quote— превращает структуры данных обратно в поток токенов через удобный синтаксис, похожий на обычный код.
Поскольку процедурные макросы — это отдельные мини-программы, компилятору нужно:
- Скомпилировать макрос и все его зависимости (
syn,quote). - Запустить его.
- Скомпилировать результат его работы. В больших проектах злоупотребление макросами может увеличить время сборки в разы. Архитекторы часто выносят макросы в отдельные крейты и стараются минимизировать их использование там, где можно обойтись дженериками.
Пример создания «абсолютно гигиеничного» пути в процедурном макросе:
// Внутри процедурного макроса плохо писать так:
// quote! { Vec::new() } // Если у пользователя нет Vec в импортах, код упадет
// Правильно писать полные пути:
quote! { ::std::vec::Vec::new() }
- Декларативные макросы хороши для простых повторений кода и создания DSL.
- Процедурные макросы — это полноценная кодогенерация на уровне AST.
- Гигиена и Spans определяют качество макроса.
- Инструментарий: используйте
cargo expand, чтобы увидеть, какой код на самом деле генерирует макрос.
Ключевое слово unsafe не отключает проверку типов и не удаляет Borrow Checker. Оно лишь дает вам пять дополнительных «суперспособностей»:
- Разыменовывать сырые указатели.
- Вызывать функции
unsafe(включая FFI). - Обращаться к полям
union. - Читать/менять статические мутабельные переменные.
- Реализовывать
unsafeтрейты.
В Rust есть Сырые указатели (*const T и *mut T). В отличие от ссылок (&T), они:
- Могут быть нулевыми (null).
- Не имеют лайфтаймов.
- Не гарантируют валидность данных.
Зачем это нужно архитектору? Для построения собственных низкоуровневых структур данных (например, связанных списков или lock-free очередей), где Borrow Checker слишком строг.
fn main() {
let mut num = 42;
let r1 = &num as *const i32; // Создание указателя безопасно
let r2 = &mut num as *mut i32;
unsafe {
// Разыменование — только в unsafe блоке!
println!("r1: {}, r2: {}", *r1, *r2);
}
}
Это важнейший инструмент. Miri — это интерпретатор MIR, который запускает ваш код и отслеживает каждое обращение к памяти. Он ловит Undefined Behavior (UB) там, где компилятор бессилен.
Правило архитектора: Весь
unsafeкод должен быть покрыт тестами и прогнан через Miri.
Rust может вызывать функции из C и наоборот. Это база для интеграции с системными API (Linux, Windows) или существующими библиотеками (OpenSSL, SQLite).
- ABI (Application Binary Interface): Чтобы Rust понял C, нужно использовать
extern "C". - Safe Wrappers: Главная задача архитектора — обернуть опасный C-вызов в безопасный Rust-интерфейс, чтобы пользователь библиотеки даже не знал об
unsafeвнутри.
Позволяет выполнять одну операцию над целым вектором данных за один такт процессора.
- Автовекторизация: Компилятор сам пытается использовать SIMD.
- Intrinsics: Прямой вызов инструкций процессора через модуль
std::simd.
Стандартный аллокатор хорош, но для высоконагруженных систем (базы данных, игровые движки) может потребоваться свой.
- Bump Allocator / Arena: Выделяет память огромным куском и «отрезает» от него части. Очистка происходит мгновенно — просто сбросом указателя в начало.
#[global_allocator]: Позволяет заменить системный аллокатор наjemallocилиmimallocодной строкой.
В этом режиме отключается стандартная библиотека. У вас нет кучи (по умолчанию), нет потоков, нет файлов. Только ядро языка (core). Это необходимо для написания драйверов, прошивок для микроконтроллеров или ядер ОС.
На системном уровне архитектор выбирает, как взаимодействовать с ядром ОС.
- Epoll/Kqueue: Классика асинхронного I/O.
- io_uring (Linux): Новый стандарт. Вместо постоянных системных вызовов (syscalls), программа и ядро обмениваются данными через кольцевые буферы в общей памяти. В Rust поддержка
io_uringобеспечивает колоссальную производительность сетевых приложений.
Пример создания безопасной обертки (Safe Wrapper):
struct MyCFile {
handle: *mut libc::FILE,
}
impl MyCFile {
pub fn open(path: &str) -> Option<Self> {
let c_str = std::ffi::CString::new(path).ok()?;
let handle = unsafe { libc::fopen(c_str.as_ptr(), b"r\0".as_ptr() as *const _) };
if handle.is_null() { None } else { Some(Self { handle }) }
}
}
// Архитектурно важно реализовать Drop, чтобы избежать утечки ресурсов C
impl Drop for MyCFile {
fn drop(&mut self) {
unsafe { libc::fclose(self.handle); }
}
}
- Unsafe — это инструмент для расширения возможностей языка, требующий ручного контроля инвариантов.
- Miri — ваш лучший друг при работе с памятью.
- FFI превращает Rust в клей для системных библиотек.
- No_std открывает дверь в мир Embedded и систем реального времени.
На уровне архитектора ваша задача — не просто написать работающий код, а создать систему, которую невозможно использовать неправильно.
Это, пожалуй, самый мощный архитектурный паттерн в Rust. Мы представляем состояния системы как разные типы данных. Если объект находится в состоянии А, у него просто физически нет методов, принадлежащих состоянию Б.
// Состояния машины
struct New;
struct Active;
struct Closed;
struct Account<S> {
id: u64,
_state: std::marker::PhantomData<S>, // Тип нулевого размера для хранения состояния
}
// Методы доступны только для новых аккаунтов
impl Account<New> {
pub fn activate(self) -> Account<Active> {
Account { id: self.id, _state: std::marker::PhantomData }
}
}
// Методы доступны только для активных
impl Account<Active> {
pub fn close(self) -> Account<Closed> {
Account { id: self.id, _state: std::marker::PhantomData }
}
}
fn main() {
let acc = Account { id: 1, _state: std::marker::PhantomData::<New> };
let active_acc = acc.activate();
// acc.close(); // ОШИБКА: метод close не определен для Account<New>
}
Архитекторский эффект: Мы перенесли бизнес-логику («нельзя закрыть неактивированный счет») в проверку типов. Баг никогда не попадет в рантайм.
Проектирование системы невозможно без понимания того, что с ней происходит в Production.
- Tracing: В Rust стандарт де-факто — библиотека
tracing. В отличие от логов, трейсы позволяют видеть структуру вызовов: какая асинхронная задача породила другую, где возникла задержка. - Structured Logging: Мы не просто пишем текст, мы записываем данные (JSON), которые потом легко анализировать в системах вроде ELK или Grafana.
Мы часто используем его для обеспечения безопасности единиц измерения или идентификаторов.
struct UserId(u64);
struct PostId(u64);
fn delete_user(id: UserId) { ... }
// delete_user(PostId(1)); // ОШИБКА: типы не совпадают
Это исключает ошибку «перепутал ID пользователя с ID поста», которая часто встречается в слаботипизированных системах.
Для архитектора проект заканчивается не компиляцией, а работающим контейнером.
- Multi-stage Docker: Мы собираем бинарник в тяжелом образе с Rust, а затем копируем его в пустой
scratchили минимальныйalpine. Результат — образ размером 10-20 МБ без лишних зависимостей. - Feature Flags: Использование
[features]вCargo.toml. Это позволяет отключать тяжелые зависимости (например, GUI или поддержку специфических СУБД) при сборке, делая бинарник максимально легким для конкретного таргета.
Если вы проектируете библиотеку (crate):
- Visibility: Используйте
pub(crate)илиpub(super), чтобы скрыть детали реализации внутри модуля, открывая наружу только минимальный интерфейс. - C-API Stability: Если вы планируете, чтобы вашу библиотеку использовали из других языков, придерживайтесь стабильного
#[repr(C)].