Memory management в Rust: стек, куча и когда данные действительно удаляются
Объясняем, как Rust управляет памятью без сборщика мусора — через владение, время жизни и Drop. Сравниваем с поведением C++ и показываем, почему подход Rust исключает целый класс ошибок.
Содержание
Memory management в Rust: стек, куча и когда данные действительно удаляются
Управление памятью — одна из тех тем, где разные языки программирования расходятся принципиально, а не в деталях. Python и Java прячут проблему за сборщиком мусора. C и C++ перекладывают её целиком на программиста. Rust выбрал третий путь: строгая система типов и правила компилятора, которые делают целый класс ошибок физически невозможным — без рантаймовых издержек GC и без ручного free.
Чтобы понять, почему этот подход работает, нужно начать с основ: как именно данные размещаются в памяти и что происходит, когда они перестают быть нужны.
Стек и куча: не просто теория
Разница между стеком и кучей — это не абстракция из учебника, а конкретный механизм, который влияет на производительность, безопасность и архитектуру программы.
Стек
Стек работает по принципу LIFO: последний вошёл — первый вышел. Каждый раз, когда вы вызываете функцию, рантайм выделяет на стеке фрейм — блок памяти под локальные переменные, аргументы и адрес возврата. Когда функция завершается, фрейм просто «снимается» со стека. Никакого поиска свободного места, никакой фрагментации.
Это работает быстро по одной причине: размер всех данных на стеке известен на этапе компиляции. i32 занимает 4 байта — всегда. [u8; 1024] занимает 1024 байта — всегда. Компилятор знает, сколько места нужно выделить ещё до запуска программы.
fn calculate(x: i32, y: i32) -> i32 {
let result = x + y; // result живёт на стеке
result // возвращаем значение — копируем его
} // фрейм уничтожается, result исчезает
Куча
Куча — это большой пул памяти, из которого программа запрашивает блоки произвольного размера в рантайме. Именно здесь живут данные, чей размер неизвестен заранее: строки переменной длины, векторы, объекты, которые должны пережить вызов функции.
Выделение памяти в куче дороже: аллокатор должен найти подходящий свободный блок, пометить его как занятый, вернуть указатель. Освобождение тоже требует явного действия — иначе память утечёт.
fn make_greeting(name: &str) -> String {
let greeting = format!("Hello, {}!", name); // данные в куче
greeting // возвращаем — передаём владение
}
String в Rust — это структура из трёх полей на стеке (указатель, длина, ёмкость), которая владеет буфером в куче. Это важный паттерн: метаданные на стеке, данные в куче.
Владение: правила, которые меняют всё
Система владения (ownership) — сердце управления памятью в Rust. Три правила, которые компилятор проверяет статически:
- У каждого значения есть ровно один владелец.
- Когда владелец выходит из области видимости, значение уничтожается.
- Владение можно передать (move), но не скопировать неявно — если тип не реализует
Copy.
Звучит просто. На практике это означает, что компилятор точно знает, в какой момент нужно освободить память — и делает это детерминированно, без GC.
Move-семантика
fn main() {
let s1 = String::from("hello"); // s1 владеет строкой
let s2 = s1; // владение передаётся s2
// println!("{}", s1); // ошибка компиляции: s1 больше не валиден
println!("{}", s2); // OK
}
В C++ аналогичный код скомпилируется, но поведение зависит от того, реализован ли move-конструктор. В Rust компилятор просто не даст использовать перемещённое значение — это ошибка на этапе компиляции, а не неопределённое поведение в рантайме.
Заимствование (borrowing)
Передавать владение при каждом вызове функции неудобно. Для этого есть заимствование — временный доступ к данным без передачи владения:
fn print_length(s: &String) {
println!("Length: {}", s.len()); // читаем, но не владеем
}
fn main() {
let s = String::from("hello");
print_length(&s); // передаём ссылку
println!("{}", s); // s всё ещё валиден
}
Ключевое правило заимствования: в любой момент времени может существовать либо любое количество неизменяемых ссылок (&T), либо ровно одна изменяемая ссылка (&mut T). Никогда — и то, и другое одновременно.
Это правило на уровне типов исключает data races в многопоточном коде и iterator invalidation в однопоточном.
Времена жизни: явная семантика временности
Времена жизни (lifetimes) — механизм, который позволяет компилятору проверить, что ссылка не переживёт данные, на которые она указывает.
Классическая ошибка в C++:
// C++ — undefined behavior
const char* get_name() {
std::string name = "Alice";
return name.c_str(); // возвращаем указатель на уничтоженные данные
}
Компилятор C++ это пропустит. Программа упадёт или выдаст мусор — в рантайме, возможно на продакшне.
В Rust эквивалентный код не скомпилируется:
// Rust — ошибка компиляции
fn get_name() -> &str {
let name = String::from("Alice");
&name // ошибка: name уничтожается, ссылка становится dangling
}
Компилятор скажет: name does not live long enough. Это не предупреждение — это ошибка, которая блокирует сборку.
Явная аннотация времён жизни
В большинстве случаев компилятор выводит времена жизни автоматически (lifetime elision). Но иногда нужна явная аннотация:
// Возвращаем ссылку на тот из двух аргументов, который длиннее.
// Компилятор должен знать: результат живёт не дольше, чем оба аргумента.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
Аннотация 'a — это не указание продолжительности жизни, а ограничение: «результат живёт не дольше, чем пересечение времён жизни x и y». Компилятор использует эту информацию, чтобы проверить корректность использования результата.
Drop: детерминированное уничтожение
В C++ деструктор вызывается, когда объект выходит из области видимости. В Rust аналог — трейт Drop:
struct DatabaseConnection {
id: u32,
}
impl Drop for DatabaseConnection {
fn drop(&mut self) {
println!("Closing connection {}", self.id);
// здесь — реальное закрытие соединения
}
}
fn main() {
let conn = DatabaseConnection { id: 42 };
println!("Using connection");
} // drop вызывается здесь автоматически
Вывод:
Using connection
Closing connection 42
Это детерминированное уничтожение: вы точно знаете, когда ресурс будет освобождён. В отличие от GC-языков, где финализатор может вызваться когда угодно — или не вызваться вовсе до завершения программы.
Порядок уничтожения
Rust гарантирует порядок: переменные уничтожаются в обратном порядке объявления. Поля структуры — в порядке объявления. Это предсказуемо и задокументировано.
fn main() {
let first = DatabaseConnection { id: 1 };
let second = DatabaseConnection { id: 2 };
let third = DatabaseConnection { id: 3 };
}
// Порядок drop: third (3), second (2), first (1)
Сравнение с C++: где именно возникают ошибки
Чтобы оценить подход Rust, полезно посмотреть на конкретные классы ошибок, которые он исключает.
Use-after-free
// C++
int* ptr = new int(42);
delete ptr;
std::cout << *ptr; // undefined behavior — читаем освобождённую память
В Rust Box<T> — умный указатель, который владеет данными в куче. После его уничтожения использовать содержимое невозможно — компилятор не даст.
// Rust
let b = Box::new(42);
drop(b);
// println!("{}", b); // ошибка компиляции: b moved/dropped
Double-free
// C++
int* ptr = new int(42);
delete ptr;
delete ptr; // undefined behavior
В Rust невозможно вызвать drop дважды на одно значение — система владения гарантирует единственного владельца.
Dangling pointers
Уже показано выше: компилятор отслеживает времена жизни и не позволяет вернуть ссылку на данные, которые будут уничтожены.
Iterator invalidation
// C++
std::vector<int> v = {1, 2, 3};
auto it = v.begin();
v.push_back(4); // может реаллоцировать вектор — it становится невалидным
std::cout << *it; // undefined behavior
В Rust правило заимствования делает это невозможным: нельзя иметь изменяемую ссылку на вектор (push_back требует &mut) одновременно с неизменяемой ссылкой на его элемент.
let mut v = vec![1, 2, 3];
let first = &v[0]; // неизменяемое заимствование
v.push(4); // ошибка: нельзя изменять v, пока есть &v[0]
println!("{}", first);
Умные указатели: когда одного владельца недостаточно
Иногда данные действительно нужно разделить между несколькими владельцами. Для этого в стандартной библиотеке есть Rc<T> и Arc<T>.
use std::rc::Rc;
fn main() {
let data = Rc::new(vec![1, 2, 3]);
let clone1 = Rc::clone(&data);
let clone2 = Rc::clone(&data);
println!("Reference count: {}", Rc::strong_count(&data)); // 3
}
// Данные уничтожаются, когда счётчик достигает 0
Rc<T> — reference counting для однопоточного кода. Arc<T> — атомарный вариант для многопоточного. Это не GC: счётчик обновляется детерминированно, уничтожение происходит в момент, когда последний владелец выходит из области видимости.
Важный нюанс: Rc<T> не решает проблему циклических ссылок — для этого есть Weak<T>. Это один из редких случаев, где Rust не даёт автоматической защиты, и программист должен думать об архитектуре.
Unsafe: честный выход из системы
Rust не притворяется, что его система типов покрывает все случаи. Для низкоуровневого кода — работы с сырыми указателями, FFI, специфических оптимизаций — есть блок unsafe:
fn main() {
let x = 42i32;
let ptr = &x as *const i32; // сырой указатель
unsafe {
println!("{}", *ptr); // разыменование — только в unsafe
}
}
unsafe не отключает borrow checker — он разрешает конкретные операции, которые компилятор не может проверить статически. Это честный контракт: вы берёте ответственность за этот конкретный участок кода, а не за всю программу.
Вся стандартная библиотека Rust построена на unsafe-примитивах, обёрнутых в безопасные абстракции. Это и есть суть подхода: небезопасные операции изолированы и явно помечены.
Практические следствия для разработчика
Система владения Rust меняет не только безопасность, но и стиль мышления. Несколько практических наблюдений:
Ошибки компиляции — это документация. Когда borrow checker отклоняет код, он обычно указывает на реальную проблему с логикой владения данными. Это не формальность — это компилятор, который говорит: «ты не продумал, кто отвечает за этот ресурс».
Архитектура становится явной. В C++ можно написать код, где непонятно, кто владеет объектом. В Rust это невозможно — структура владения всегда выражена в типах.
Производительность предсказуема. Нет GC-пауз, нет неожиданных аллокаций. Это критично для систем реального времени, игровых движков, встраиваемых систем.
Тем, кто хочет разобраться в этих концепциях системно — от базовых типов до многопоточности и async/await — стоит посмотреть на курс по Rust от «sudo teach IT»: там эти темы разбираются последовательно, с практическими задачами, а не только в теории.
Вывод
Rust не изобрёл новую математику. Move-семантика есть в C++11, RAII — ещё старше, reference counting — стандартный приём. Rust взял эти идеи и сделал их обязательными на уровне системы типов, убрав возможность случайно их нарушить.
Результат — язык, где целый класс ошибок памяти (use-after-free, double-free, dangling pointers, data races) исключён статически, а не обнаруживается в рантайме инструментами вроде Valgrind или AddressSanitizer. Это не серебряная пуля: кривая обучения крутая, borrow checker поначалу кажется враждебным, а некоторые паттерны требуют нетривиальных решений. Но когда код компилируется — он, как правило, работает корректно в части управления памятью.
Для тех, кто хочет разобраться в теме глубже и выстроить понимание от основ до продвинутых паттернов, курс по Rust — один из способов сделать это структурированно, не собирая материал по кускам из разных источников.
Понимание того, как Rust управляет памятью, меняет то, как вы думаете о ресурсах в любом языке. Даже если вы в итоге пишете на Go или C++ — концепции владения и времён жизни становятся частью вашего инструментария мышления.
Комментарии
Пока нет комментариев