В языках с ручным управлением памятью (C/C++) вы отвечаете за освобождение каждого выделенного блока — забыли free/delete — утечка, освободили дважды — double free, обратились после освобождения — use-after-free. В языках с garbage collector (Go, Python, Java) эти проблемы решает рантайм ценой пауз GC и дополнительных накладных расходов на трекинг объектов.
Rust выбирает третий путь: система владения (ownership) — набор правил, проверяемых компилятором на этапе компиляции, при этом без рантайм-сборщика мусора. Это называют "модель без GC, но с гарантиями GC-языков".
drop.Copy).Чтобы понять владение, важно понимать, где живут данные. Стек — быстрая память фиксированного размера, LIFO, размер значения должен быть известен на этапе компиляции. Куча — динамическая память, выделяется через аллокатор, доступ к ней — через указатель, хранящийся на стеке.
| Характеристика | Стек (stack) | Куча (heap) |
|---|---|---|
| Скорость выделения | Очень быстрая (сдвиг указателя) | Медленнее (поиск свободного блока) |
| Размер | Известен на этапе компиляции | Может быть динамическим |
| Пример типов | i32, bool, [i32; 5], (i32, f64) | String, Vec<T>, Box<T> |
| Освобождение | Автоматически при выходе из scope | Через drop, вызываемый владельцем |
fn main() {
let x = 5; // на стеке, известный размер
let s = String::from("hi"); // указатель/len/capacity на стеке, буфер байт — на куче
} // здесь s выходит из scope → вызывается String::drop → освобождается куча
// x просто исчезает со стека, ничего дополнительно освобождать не нужно
String внутри — это тройка (ptr, len, capacity), лежащая на стеке, а сами байты строки — на куче. Именно поэтому String не реализует Copy: копирование тройки без копирования буфера привело бы к двум владельцам одной и той же кучи.
Когда значение типа, не реализующего Copy (например, String, Vec<T>), присваивается другой переменной или передаётся в функцию, происходит move: владение переходит к новому месту, а исходная переменная становится недействительной. Это не глубокое копирование и не просто копирование указателя "как в C" — это перенос ответственности за освобождение.
fn main() {
let s1 = String::from("hello");
let s2 = s1; // move: владение переходит от s1 к s2
// println!("{s1}"); // ОШИБКА КОМПИЛЯЦИИ:
// error[E0382]: borrow of moved value: `s1`
// value borrowed here after move
println!("{s2}"); // ок, s2 — единственный владелец
}
Почему так, а не как в Python (где s2 = s1 — просто вторая ссылка на тот же объект)? Если бы Rust позволял двум переменным владеть одним буфером на куче, при выходе обеих из scope произошёл бы двойной free — классический double-free баг. Запрет на использование s1 после move устраняет эту проблему статически, без рантайм-подсчёта ссылок.
fn takes_ownership(s: String) {
println!("{s}");
} // s выходит из scope, буфер освобождается
fn main() {
let s = String::from("world");
takes_ownership(s); // s перемещена в функцию
// println!("{s}"); // ОШИБКА: s больше не наш владелец
}
Не все типы move'ятся при присваивании. Простые типы фиксированного размера, целиком лежащие на стеке (целые числа, bool, char, кортежи и массивы из Copy-типов), реализуют трейт Copy — для них присваивание делает побитовую копию, а исходная переменная остаётся действительной.
fn main() {
let x = 5;
let y = x; // не move, а копирование — i32 реализует Copy
println!("{x} {y}"); // ок, обе переменные валидны
}
Почему i32 — Copy, а String — нет? Дело не в размере, а в том, владеет ли тип ресурсами на куче. Копирование i32 — это просто дублирование 4 байт на стеке, дешёвая и безопасная операция. Копирование String "как есть" (побитово) продублировало бы указатель на кучу — и тогда у одного буфера оказалось бы два владельца, что и запрещает move-семантика. Для типов с ресурсами на куче явное глубокое копирование доступно через трейт Clone.
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // явное глубокое копирование буфера на куче
println!("{s1} {s2}"); // обе валидны — два независимых буфера
}
#[derive(Clone, Copy, Debug)]
struct Point {
x: i32,
y: i32,
}
// Point можно вывести Copy, т.к. все его поля — Copy-типы
| Copy | Clone | |
|---|---|---|
| Когда срабатывает | Неявно, при присваивании/передаче | Только явный вызов .clone() |
| Стоимость | Дешёвая (побитовое копирование стека) | Может быть дорогой (аллокация + копирование кучи) |
| Кто реализует | Простые Stack-only типы | Практически любой тип, включая Copy-типы |
| Требование | Все поля должны быть Copy | Нет ограничений |
.clone() где попало. Это компилируется, но на Senior-уровне каждый .clone() — сигнал: либо это осознанная цена за упрощение владения, либо признак того, что архитектуру стоит пересмотреть (использовать ссылки, Rc, реструктурировать код).
Передача владения в каждую функцию неудобна — пришлось бы возвращать значение обратно, чтобы продолжить им пользоваться. Заимствование (borrowing) позволяет дать временный доступ к значению без передачи владения — через ссылку.
fn calculate_length(s: &String) -> usize { // заимствуем, не забираем владение
s.len()
} // s выходит из scope, но т.к. это ссылка — ничего не удаляется
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // передаём &String, не String
println!("Длина '{s1}' — {len}"); // s1 всё ещё владеет строкой!
}
Для изменения заимствованного значения нужна изменяемая ссылка &mut T, и переменная-источник тоже обязана быть mut:
fn append_world(s: &mut String) {
s.push_str(", world");
}
fn main() {
let mut s = String::from("hello");
append_world(&mut s);
println!("{s}"); // hello, world
}
Компилятор проверяет два ключевых правила для каждого значения в каждый момент времени в его области видимости:
&mut T) — и никаких других ссылок.&T) одновременно — но ни одной изменяемой.Эти правила действуют одновременно с ещё одним: ссылка не может пережить (outlive) значение, на которое указывает — это отдельно проверяется через анализ времён жизни (lifetimes, см. тему 09). Вместе эти правила статически исключают data race и use-after-free.
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s; // ок: несколько неизменяемых ссылок одновременно
println!("{r1} и {r2}");
// r1, r2 больше не используются после этой точки (NLL — non-lexical lifetimes)
let r3 = &mut s; // ок: r1/r2 уже "мертвы", можно брать &mut
r3.push_str("!");
println!("{r3}");
}
fn main() {
let mut s = String::from("hello");
let r1 = &s; // неизменяемое заимствование
let r2 = &mut s; // ОШИБКА КОМПИЛЯЦИИ:
// error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
println!("{r1} {r2}"); // r1 используется здесь — значит, живёт до этого момента
}
// Почему это опасно, если бы компилятор разрешил: r2 мог бы реаллоцировать
// буфер строки, и r1 стал бы dangling pointer — use-after-free.
fn main() {
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // ОШИБКА КОМПИЛЯЦИИ:
// error[E0499]: cannot borrow `s` as mutable more than once at a time
r1.push_str(" a");
r2.push_str(" b");
}
// Это в точности тот случай, который в многопоточном коде называется data race:
// два "писателя" в одну память без синхронизации. Borrow checker запрещает это
// даже в однопоточном коде — на уровне общей модели памяти языка.
fn dangle() -> &String { // ОШИБКА КОМПИЛЯЦИИ:
let s = String::from("hello");
&s // error[E0106]: missing lifetime specifier /
// s не живёт достаточно долго — она удалится в конце функции,
// а мы пытаемся вернуть ссылку на уже освобождённые данные
} // s.drop() вызывается здесь — но мы уже вернули ссылку на неё!
// Правильно: вернуть владение, а не ссылку
fn no_dangle() -> String {
String::from("hello") // move наружу — владение передаётся вызывающему коду
}
Современный borrow checker анализирует не лексическую область видимости переменной (до закрывающей }), а фактический "живой" диапазон использования ссылки — до последнего места, где она реально используется. Это делает борроу-чекер значительно менее строгим, чем в ранних версиях Rust (до 2018 edition).
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
println!("{first}"); // последнее использование first — здесь
v.push(4); // ок в современном Rust: first уже "не жив" на этой строке
}
Move-семантика проявляется и там, где новички её не ждут — в замыканиях и цепочках итераторов.
fn main() {
let data = vec![1, 2, 3];
let closure = move || {
println!("{data:?}"); // data перемещена внутрь замыкания
};
closure();
// println!("{data:?}"); // ОШИБКА: data была перемещена в closure
// into_iter() забирает владение элементами, iter() — заимствует
let v = vec![String::from("a"), String::from("b")];
for s in v.into_iter() {
println!("{s}"); // s — владелец String
}
// v здесь уже недоступна — into_iter() переместил все элементы
}
Выбор между "брать по значению", "брать по ссылке" и "брать по изменяемой ссылке" — не формальность, а архитектурное решение, влияющее на API и производительность.
| Сигнатура | Семантика | Когда применять |
|---|---|---|
fn f(s: String) | Функция забирает владение | Функции нужно сохранить/изменить/вернуть значение как часть результата |
fn f(s: &str) | Только чтение | Функции достаточно прочитать данные — самый гибкий вариант API |
fn f(s: &mut String) | Изменение без передачи владения | Нужно мутировать существующее значение вызывающего кода |
fn f(s: &String) | Излишне узкий тип | Почти всегда стоит заменить на &str — работает и со срезами, и со String |
// Хуже: заставляет вызывающий код клонировать или отдавать владение зря
fn print_len_owned(s: String) -> usize {
s.len()
}
// Лучше: работает и со String (через deref-coercion), и с &str, без лишних move
fn print_len(s: &str) -> usize {
s.len()
}
fn main() {
let owned = String::from("hello");
print_len(&owned); // &String автоматически приводится к &str
print_len("literal"); // строковый литерал уже &str
}
&str вместо String, &[T] вместо Vec<T>), если функции не нужно владение. Это делает API гибче и избавляет вызывающий код от ненужных .clone().
| Язык | Модель управления памятью | Цена |
|---|---|---|
| C/C++ | Ручное управление (malloc/free, new/delete) | Максимальный контроль, но риск leak/UAF/double-free целиком на разработчике |
| Go | Трассирующий GC | Простота, но паузы GC и накладные расходы на трекинг указателей |
| Python | Reference counting + cyclic GC | Простота, но накладные расходы на каждый инкремент/декремент счётчика, GIL |
| Rust | Ownership, проверяемый статически компилятором | Издержки на этапе обучения и написания кода ("борьба с borrow checker"), но нулевой рантайм-оверхед |
Общее правило: если код в принципе безопасен относительно памяти, Rust почти всегда даст его скомпилировать — но требует, чтобы эта безопасность была видна компилятору статически, что иногда заставляет переписывать код в более "объяснимом" для анализатора виде (например, разбивать заимствование структуры на заимствование отдельных полей).