Компилятор rustc проходит через несколько промежуточных представлений, каждое из которых упрощает исходный код для следующего этапа анализа или оптимизации.
for превращён в цикл с Iterator, синтаксический сахар убран, здесь работает вывод типов (type inference).$ rustc --emit=mir main.rs # вывести MIR в файл
$ rustc --emit=llvm-ir main.rs # вывести LLVM IR
$ cargo rustc -- --emit=asm # итоговый ассемблер
MIR — это граф базовых блоков (basic blocks), похожий на байт-код: каждый блок содержит последовательность простых операций (присваивания, вызовы) и заканчивается терминатором (переход, условный переход, возврат, паника). В отличие от AST/HIR, здесь явно видны все точки, где значение может быть перемещено, заимствовано или уничтожено (drop).
fn example(cond: bool) -> i32 {
let x = 10;
if cond {
x + 1
} else {
x + 2
}
}
// упрощённый вид MIR (структура, не точный вывод rustc):
fn example(_1: bool) -> i32 {
let _0: i32; // возвращаемое значение
let _2: i32; // x
let _3: i32; // временное для x + 1 / x + 2
bb0: {
_2 = const 10_i32;
switchInt(_1) -> [0: bb2, otherwise: bb1];
}
bb1: {
_3 = Add(_2, const 1_i32);
_0 = _3;
goto -> bb3;
}
bb2: {
_3 = Add(_2, const 2_i32);
_0 = _3;
goto -> bb3;
}
bb3: {
return;
}
}
Borrow checker анализирует именно этот граф: он строит для каждой переменной множество точек программы, в которых заимствование должно оставаться валидным, и проверяет непротиворечивость (нет одновременного &mut и любого другого доступа). MIR также используется для мономорфизации generics, constant evaluation (const fn) и оптимизаций уровня компилятора Rust ещё до передачи в LLVM (например, устранение неиспользуемых drop-глифов).
До Rust 2018 время жизни заимствования формально длилось до конца лексической области видимости (до закрывающей }), даже если переменная фактически больше не использовалась. NLL (стабилизирован в Rust 2018) сделал анализ заимствований точным по потоку управления на MIR: заимствование заканчивается там, где происходит последнее реальное использование, а не там, где заканчивается блок.
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0]; // заимствование &v начинается
println!("{first}"); // последнее использование `first` — здесь заимствование "фактически" кончается
v.push(4); // до NLL: ошибка компиляции (заимствование ещё "живо" лексически)
// с NLL: компилируется — компилятор видит, что first больше не используется
}
Без NLL (в старом lexical-lifetime анализе) этот код не компилировался бы, потому что заимствование &v формально пересекалось бы с областью видимости first до конца блока — даже несмотря на то, что first не используется после println!. NLL требует MIR, потому что нужен граф потока управления, а не текстовые границы блоков — именно поэтому анализ заимствований физически не мог быть точным до появления MIR как промежуточного представления.
Generic-функция в Rust не компилируется в единый код с диспетчеризацией по типу в рантайме (как стёртые дженерики в Java или тип-параметризованный байткод) — вместо этого компилятор генерирует отдельную копию машинного кода для каждой уникальной комбинации типов-параметров, с которыми функция реально вызывается в программе. Это называется мономорфизацией (monomorphization).
fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
let mut largest = list[0];
for &item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let ints = vec![1, 5, 3];
let floats = vec![1.2, 5.6, 3.1];
largest(&ints); // компилятор генерирует largest::<i32>
largest(&floats); // и отдельно largest::<f64>
}
// в бинарнике окажутся ДВЕ независимые, полностью оптимизированные функции —
// как если бы вы вручную написали largest_i32 и largest_f64
Плюс — каждая мономорфизированная версия оптимизируется индивидуально: LLVM видит конкретный тип, может инлайнить операции сравнения, использовать нужные регистры и SIMD-инструкции — отсюда "zero-cost": вызов generic-функции по итоговому машинному коду неотличим от вызова функции, написанной вручную под конкретный тип. Минус — каждая уникальная комбинация типов даёт собственную копию машинного кода, что увеличивает размер бинарника (code bloat) и время компиляции при большом числе инстанциаций.
// Способ снизить code bloat: вынести неполиморфную часть логики
// в отдельную non-generic функцию, чтобы мономорфизировался только тонкий слой
fn process<P: AsRef<str>>(path: P) -> std::io::Result<String> {
process_inner(path.as_ref()) // generic-обёртка тонкая, мономорфизируется много раз, но дёшево
}
fn process_inner(path: &str) -> std::io::Result<String> {
std::fs::read_to_string(path) // вся тяжёлая логика — не generic, существует в одном экземпляре
}
Альтернатива мономорфизации — dyn Trait, использующий диспетчеризацию через таблицу виртуальных функций (vtable), как в C++ или Go-интерфейсах. Значение типа &dyn Trait или Box<dyn Trait> — это "толстый указатель" (fat pointer): пара из указателя на данные и указателя на vtable с адресами методов конкретного типа.
trait Shape {
fn area(&self) -> f64;
}
struct Circle { radius: f64 }
struct Square { side: f64 }
impl Shape for Circle {
fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }
}
impl Shape for Square {
fn area(&self) -> f64 { self.side * self.side }
}
fn main() {
// один Vec может хранить разные конкретные типы за общим интерфейсом —
// это то, что мономорфизация в принципе не может выразить в одной коллекции
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 2.0 }),
Box::new(Square { side: 3.0 }),
];
for shape in &shapes {
println!("{}", shape.area()); // вызов через vtable: индирекция на этапе рантайма
}
}
Вызов shape.area() компилируется в чтение адреса функции из vtable по фиксированному смещению и косвенный вызов (indirect call) — это на 1-2 обращения к памяти дороже прямого вызова и мешает инлайнингу (компилятор не знает статически, какая реализация будет вызвана).
| Критерий | Generics (мономорфизация) | dyn Trait (vtable) |
|---|---|---|
| Диспетчеризация | статическая, на этапе компиляции | динамическая, через таблицу указателей |
| Инлайнинг вызовов | возможен полностью | затруднён (indirect call) |
| Размер бинарника | растёт с числом инстанциаций | одна копия кода для всех типов |
| Гетерогенные коллекции | невозможны напрямую | естественны (Vec<Box<dyn Trait>>) |
| Время компиляции | больше при многих типах | меньше, код скомпилирован один раз |
dyn Trait, когда нужна гетерогенная коллекция, плагинная архитектура, или когда мономорфизация грозит неприемлемым раздутием бинарника.
По умолчанию паника в Rust запускает stack unwinding — раскрутку стека кадр за кадром с вызовом деструкторов (Drop::drop) для всех живых на данный момент значений, аналогично исключениям в C++. Если паника происходит внутри другой паники (например, в Drop::drop) — процесс немедленно завершается через abort.
struct Guard(String);
impl Drop for Guard {
fn drop(&mut self) {
println!("освобождение: {}", self.0);
}
}
fn might_panic() {
let _g = Guard("resource".into());
panic!("что-то пошло не так"); // _g.drop() всё равно выполнится при unwinding
}
fn main() {
let result = std::panic::catch_unwind(|| {
might_panic();
});
println!("паника перехвачена: {}", result.is_err());
}
Альтернативный режим — panic = "abort" в профиле сборки: паника сразу завершает процесс без раскрутки стека и вызова деструкторов. Это уменьшает размер бинарника (не нужны unwinding-таблицы) и немного ускоряет обычный путь выполнения, но делает catch_unwind бесполезным — паника становится необратимым завершением процесса.
// Cargo.toml
[profile.release]
panic = "abort" # нельзя перехватить панику через catch_unwind; часто используется
# для встраиваемых систем и минимизации размера бинарника
catch_unwind не предназначен для обработки штатных ошибок (для этого есть Result) — он нужен на границах, где паника не должна пересечь FFI-границу или обрушить весь процесс целиком (например, в потоковом пуле веб-сервера, где паника одного запроса не должна валить остальные).
В отличие от Go, Java, C# или языков со сборкой мусора, у Rust нет фонового потока/паузы GC и нет обязательного управляющего рантайма поверх операционной системы. Управление памятью полностью статично выведено компилятором через систему владения (ownership) и заимствований (borrowing): каждое значение освобождается детерминированно, ровно в момент выхода владельца из области видимости, через вызов Drop::drop, вставленный компилятором в MIR.
fn main() {
{
let data = Vec::<u8>::with_capacity(1_000_000);
// работа с data
} // здесь компилятор вставляет вызов drop(data) — освобождение немедленное,
// без ожидания цикла сборщика мусора
println!("память уже освобождена");
}
| Аспект | Rust (ownership) | Go/Java (GC) |
|---|---|---|
| Момент освобождения | детерминированный, в конце области видимости | недетерминированный, решает GC |
| Паузы выполнения | отсутствуют (нет GC pause) | возможны stop-the-world или конкурентные паузы |
| Накладные расходы в рантайме | отсутствуют — вся проверка на этапе компиляции | работа GC-потока/трассировки постоянно фоном |
| Рантайм-компоненты | минимальный std (аллокатор, panic handler) | полноценная VM/рантайм с планировщиком, GC |
| Предсказуемость latency | высокая | ниже из-за пауз GC |
"Отсутствие рантайма" не означает полное отсутствие поддерживающего кода — у Rust всё равно есть минимальный слой: обработчик паники, точка входа _start/main, аллокатор. Но там нет: сборщика мусора, JIT-компилятора, зелёных потоков с собственным планировщиком (в отличие от горутин Go) — асинхронные задачи (async fn) в Rust компилируются в конечные автоматы (state machines) на этапе компиляции и требуют внешнего executor'а (Tokio, async-std), который сам по себе — обычная библиотека, а не встроенная часть языка.