← Назад к списку тем

02. Владение и заимствование

Move-семантика, Copy vs Clone, ссылки &T/&mut T, правила заимствования, стек vs куча.

Зачем нужно владение: проблема, которую решает Rust

В языках с ручным управлением памятью (C/C++) вы отвечаете за освобождение каждого выделенного блока — забыли free/delete — утечка, освободили дважды — double free, обратились после освобождения — use-after-free. В языках с garbage collector (Go, Python, Java) эти проблемы решает рантайм ценой пауз GC и дополнительных накладных расходов на трекинг объектов.

Rust выбирает третий путь: система владения (ownership) — набор правил, проверяемых компилятором на этапе компиляции, при этом без рантайм-сборщика мусора. Это называют "модель без GC, но с гарантиями GC-языков".

  • Каждое значение в Rust имеет ровно одного владельца (owner) — переменную.
  • Когда владелец выходит из области видимости (scope), значение автоматически освобождается — вызывается drop.
  • Владение может быть передано (moved) другой переменной, но не может существовать у двух переменных одновременно (кроме типов, реализующих Copy).
🔑 Ключевая идея: Ownership — это не рантайм-механизм, а статический анализ. Вся "магия" происходит на этапе компиляции и не добавляет накладных расходов во время выполнения — в этом принципиальное отличие от GC и от подсчёта ссылок (reference counting) в чистом виде.

Стек vs куча

Чтобы понять владение, важно понимать, где живут данные. Стек — быстрая память фиксированного размера, 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: копирование тройки без копирования буфера привело бы к двум владельцам одной и той же кучи.

Move-семантика

Когда значение типа, не реализующего 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 больше не наш владелец
}

Copy vs Clone

Не все типы 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-типы
CopyClone
Когда срабатываетНеявно, при присваивании/передачеТолько явный вызов .clone()
СтоимостьДешёвая (побитовое копирование стека)Может быть дорогой (аллокация + копирование кучи)
Кто реализуетПростые Stack-only типыПрактически любой тип, включая Copy-типы
ТребованиеВсе поля должны быть CopyНет ограничений
⚠️ Не злоупотребляйте .clone(): Начинающие Rust-разработчики часто "лечат" ошибки borrow-checker вызовом .clone() где попало. Это компилируется, но на Senior-уровне каждый .clone() — сигнал: либо это осознанная цена за упрощение владения, либо признак того, что архитектуру стоит пересмотреть (использовать ссылки, Rc, реструктурировать код).

Ссылки и заимствование: &T и &mut T

Передача владения в каждую функцию неудобна — пришлось бы возвращать значение обратно, чтобы продолжить им пользоваться. Заимствование (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
}

Правила заимствования (borrow checker)

Компилятор проверяет два ключевых правила для каждого значения в каждый момент времени в его области видимости:

  1. Либо одна изменяемая ссылка (&mut T) — и никаких других ссылок.
  2. Либо сколько угодно неизменяемых ссылок (&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}");
}

Пример, который НЕ компилируется №1: смешение &mut и &

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.

Пример, который НЕ компилируется №2: две &mut одновременно

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 запрещает это
// даже в однопоточном коде — на уровне общей модели памяти языка.

Пример, который НЕ компилируется №3: dangling reference

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 наружу — владение передаётся вызывающему коду
}
🚫 В C/C++ это было бы UB, а не ошибкой компиляции: Аналогичный код на C++ (возврат указателя/ссылки на локальную переменную) чаще всего компилируется без единого предупреждения и приводит к undefined behavior во время выполнения — иногда работает "случайно", иногда крашится непредсказуемо. Rust ловит это статически, до запуска программы.

Non-Lexical Lifetimes (NLL)

Современный 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 в замыканиях и итераторах

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() переместил все элементы
}

Ownership при передаче в функции: паттерны Senior-уровня

Выбор между "брать по значению", "брать по ссылке" и "брать по изменяемой ссылке" — не формальность, а архитектурное решение, влияющее на 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 и накладные расходы на трекинг указателей
PythonReference counting + cyclic GCПростота, но накладные расходы на каждый инкремент/декремент счётчика, GIL
RustOwnership, проверяемый статически компиляторомИздержки на этапе обучения и написания кода ("борьба с borrow checker"), но нулевой рантайм-оверхед

Общее правило: если код в принципе безопасен относительно памяти, Rust почти всегда даст его скомпилировать — но требует, чтобы эта безопасность была видна компилятору статически, что иногда заставляет переписывать код в более "объяснимом" для анализатора виде (например, разбивать заимствование структуры на заимствование отдельных полей).