Почему Rust не нуждается в сборщике мусора, но при этом безопасен?
Когда разработчики, привыкшие к Python, Java или C#, впервые слышат о Rust, их часто пугает отсутствие привычного сборщика мусора (GC). «Как же так? — думают они. — Без GC мы вернёмся в эпоху ручного управления памятью, утечек и segmentation fault?»
Спойлер: нет, не вернёмся. Rust предлагает третий путь — систему владения (ownership), которая гарантирует безопасность памяти на этапе компиляции. Никакого рантайма, никакого GC, никаких висячих указателей. Только чёткие правила и компилятор, который не даст вам выстрелить себе в ногу.
В этой статье мы разберём, как устроена память в Rust, что такое RAII, владение, заимствование и время жизни, и почему всё это делает Rust одним из самых надёжных языков системного программирования.
Как устроена память в Rust: стек и куча
Как и в любом языке низкого уровня, память в Rust делится на два основных региона:
- Стек (stack) — быстрая, ограниченная по размеру память, работающая по принципу LIFO. Здесь хранятся локальные переменные, аргументы функций и возвращаемые адреса.
- Куча (heap) — более медленная, но гибкая память, используемая для данных, размер которых неизвестен на этапе компиляции (например,
String,Vec,Box).
В C++ вы сами решаете, когда выделять и освобождать кучу (через new/delete). В Java или Python за вас это делает сборщик мусора, периодически останавливая программу. Rust же использует систему владения, которая автоматически освобождает память, как только переменная выходит из области видимости.
Пример: базовое выделение памяти
fn main() {
let s = String::from("Привет, мир!"); // выделение памяти в куче
println!("{}", s);
} // здесь s выходит из области видимости, память освобождается
Обратите внимание: никакого free() или delete. В конце блока компилятор неявно вставляет вызов деструктора — drop(). Это называется RAII (Resource Acquisition Is Initialization) — ресурс захватывается при инициализации и освобождается при разрушении объекта.
Владение (Ownership): главное правило Rust
Система владения в Rust строится на трёх простых правилах:
- У каждого значения в Rust есть ровно один владелец (owner).
- Когда владелец выходит из области видимости, значение удаляется.
- Владельца можно передать (move) или временно одолжить (borrow).
Звучит скучно? Давайте посмотрим, как это работает на практике.
Перемещение (Move)
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1 перемещается в s2
// println!("{}", s1); // Ошибка! s1 больше не владеет данными
println!("{}", s2); // OK
}
В C++ такое присваивание привело бы к двум указателям на одну память — и к двойному освобождению. В Rust компилятор просто запрещает использовать s1 после перемещения. Никаких утечек, никаких двойных удалений.
Копирование (Copy)
Простые типы, такие как целые числа, булевы значения и символы, реализуют трейт Copy. Для них присваивание создаёт полную копию:
fn main() {
let x = 42;
let y = x;
println!("x = {}, y = {}", x, y); // Оба доступны!
}
Почему так? Потому что i32 хранится на стеке, и его копирование — это просто копирование нескольких байтов. Дешёво и безопасно.
Заимствование (Borrowing) и ссылки
Что делать, если нужно временно дать доступ к данным, не передавая владение? Использовать ссылки. Rust позволяет заимствовать значение по ссылке.
fn main() {
let s = String::from("Rust");
let len = calculate_length(&s); // передаём ссылку, а не владение
println!("Длина '{}' равна {}", s, len); // s всё ещё доступна
}
fn calculate_length(s: &String) -> usize {
s.len()
} // здесь ссылка s выходит из области видимости, но String не удаляется
Ссылки бывают двух видов:
- Неизменяемые (
&T) — можно читать, но нельзя изменять. Можно иметь сколько угодно таких ссылок. - Изменяемые (
&mut T) — можно читать и изменять. Только одна такая ссылка может существовать в данный момент.
Это правило — гарантия отсутствия гонок данных (data races). Компилятор не позволит вам одновременно читать и писать в одну память из разных частей программы.
Пример: изменяемая ссылка
fn main() {
let mut s = String::from("hello");
change(&mut s);
println!("{}", s); // "hello, world"
}
fn change(s: &mut String) {
s.push_str(", world");
}
Время жизни (Lifetimes): как Rust проверяет корректность ссылок
Теперь самое интересное. Компилятор должен убедиться, что ссылка не переживёт данные, на которые указывает. Для этого он использует механизм времён жизни (lifetimes).
В большинстве случаев Rust выводит времена жизни автоматически. Но иногда приходится указывать их явно — например, в функциях, которые возвращают ссылки.
fn longest(x: &str, y: &str) -> &str { // Ошибка! Нужно указать время жизни
if x.len() > y.len() { x } else { y }
}
Компилятор не знает, какая из ссылок x или y будет возвращена, а значит, не может определить время жизни результата. Исправляем:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
Здесь 'a — это параметр времени жизни. Он говорит: «Результат будет жить не дольше, чем оба аргумента». Компилятор теперь может проверить, что вызывающий код не использует возвращённую ссылку после того, как исходные данные удалены.
Сборщик мусора в Rust? Нет, RAII и умные указатели
В Rust нет ни GC, ни ручного управления памятью. Вместо этого используется RAII в сочетании с умными указателями (Box, Rc, Arc