У каждого языка есть своя плата за удобство. Java платит паузами сборщика мусора, C++ разрешает ошибиться и расплачивается падениями процессов, Rust идёт третьим путём: памятью управляет не рантайм, а правила, которые компилятор проверяет ещё до запуска программы. Проверяющий проход называется borrow checker, и именно с ним у большинства новичков связаны и первая неделя отчаяния, и главный аргумент остаться в языке. Чтобы плата не выглядела абстракцией, полезно видеть систему целиком: механику перемещений, частые ошибки с кодами, историю послаблений и честную цену освоения.

Что на самом деле проверяет borrow checker при компиляции

Borrow checker не отдельная утилита, а проход компилятора rustc, который опирается на модель владения. У каждого значения ровно один владелец, и когда владелец покидает область видимости, компилятор вставляет освобождение памяти. Ссылок два вида: &T неизменяемая, читателей допускается сколько угодно, и &mut T изменяемая, она всегда одна. Первый фрагмент собирается, второй нет:

fn main() {
    let s = String::from("вектор");
    let r1 = &s;
    let r2 = &s;
    println!("{} {}", r1, r2);
}
fn main() {
    let mut s = String::from("вектор");
    let r1 = &s;
    let r2 = &mut s;
    r2.push_str("!");
    println!("{}", r1);
}

error[E0502]: cannot borrow s as mutable because it is also borrowed as immutable. Правило сформулировано нарочно коротко: либо много читателей, либо один писатель. За простотой стоит защита от сценария, где одна часть кода читает память, которую другая уже переписала или освободила. Компилятор не угадывает намерения, он требует схему доступа, в которой конфликт невозможен в принципе.

Три правила владения и знакомство с ошибкой E0382

Система укладывается в три правила, которые применяются к каждому значению в программе:

  1. у значения ровно один владелец, и память освобождается при его выходе из области видимости;
  2. значение можно передать новому владельцу, после чего прежний использовать нельзя;
  3. одновременно живут либо сколько угодно неизменяемых ссылок, либо одна изменяемая, но не оба вида сразу.

Второе правило порождает самую частую ошибку первых дней:

fn main() {
    let s1 = String::from("заголовок");
    let s2 = s1;
    println!("{}", s1);
}

error[E0382]: borrow of moved value: s1. String хранит три поля: указатель на байты в куче, длину и ёмкость. Присваивание копирует эти три значения и помечает s1 недействительным, поэтому обращение к s1 запрещено ещё на компиляции. В C++ после похожего присваивания обе переменные указывают на один буфер, и деструкторы попытаются освободить его дважды. Rust ломает эту цепочку до запуска. Команда rustc --explain E0382 печатает развёрнутое объяснение прямо в терминале, свой разбор есть у каждого кода ошибки.

Перемещение работает и на вызовах функций, аргумент по значению забирает владение целиком:

fn consume(s: String) {
    println!("{}", s);
}

fn main() {
    let s = String::from("отчёт");
    consume(s);
    println!("{}", s);
}

Сборка падает с тем же E0382. Отсюда рабочие привычки: крупные значения передавать по ссылке &str или &String, клонировать только там, где копия действительно нужна. Глубокая копия требует явного вызова clone(), и это честно: копирование буфера в куче стоит памяти и времени, язык не станет делать это тайком:

fn main() {
    let s1 = String::from("заголовок");
    let s2 = s1.clone();
    println!("{} {}", s1, s2);
}

Простые типы вроде i32, f64, bool и char, а также массивы и кортежи из них копируются побитово без перемещения, для них запись s2 = s1 не отключает s1. Разница объясняется просто: число живёт целиком на стеке, у String есть ресурс в куче, который нельзя отдать дважды без следа.

Ошибки E0502 E0506 и ловушки на векторах в циклах и замыканиях

Классика жанра, о которую спотыкается каждый начинающий:

fn main() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];
    v.push(4);
    println!("{}", first);
}

error[E0502]: cannot borrow v as mutable because it is also borrowed as immutable. Причина конкретная: push может исчерпать ёмкость и выделить новый буфер в куче, после чего first указывал бы в уже освобождённую память. В C++ та же последовательность даёт неопределённое поведение, которое всплывает аварийным завершением в проде или строкой в отчёте санитайзера. В Rust это ошибка компиляции с человеческим объяснением. Тот же класс ловушки встречается в цикле:

fn main() {
    let mut v = vec![1, 2, 3];
    for x in &v {
        v.push(*x);
    }
}

Итератор держит неизменяемое заимствование всей коллекции до конца цикла, push требует изменяемое. Рабочее решение сначала вычисляет новые элементы, затем расширяет вектор:

fn main() {
    let mut v = vec![1, 2, 3];
    let extra: Vec<i32> = v.iter().map(|x| x * 10).collect();
    v.extend(extra);
    println!("{:?}", v);
}

Отдельная ловушка ждёт в замыканиях. Замыкание, меняющее переменную из окружения, держит изменяемое заимствование до своего последнего вызова:

fn main() {
    let mut count = 0;
    let mut inc = || count += 1;
    inc();
    println!("{}", count);
    inc();
}

Печать count между вызовами даёт error[E0502]: cannot borrow count as immutable because it is also borrowed as mutable. Компилятор прав: инкремент может вызваться снова, значит ссылка замыкания обязана жить. Замыкания делятся на три трейта: Fn захватывает окружение по ссылке, FnMut по изменяемой ссылке, FnOnce по значению, и принадлежность замыкания к трейту определяется тем, как оно обращается с переменными снаружи.

Рядом живёт error[E0506]: cannot assign to v because it is borrowed, попытка записать в переменную, пока активна ссылка на неё. Общая схема ответа одна: менять порядок операций и структуру данных, а не воевать с компилятором, потому что за каждой жалобой стоит реальный сценарий повреждения памяти, а не каприз.

Non lexical lifetimes и новые редакции Rust меняют границы проверки

До Rust 1.31, вышедшего в декабре 2018 года вместе с редакцией 2018, заимствование жило до конца лексической области видимости, и такой код не собирался:

fn main() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];
    println!("{}", first);
    v.push(4);
}

first уже никому не нужен, но старый анализатор держал заимствование до конца функции. Non lexical lifetimes, коротко NLL, научили компилятор завершать заимствование в точке последнего использования, и пример собрался. Знаменитый предел, обход односвязного списка на месте, остался незакрытым даже после NLL: код, безопасный по смыслу, отклонялся из за пересекающихся заимствований в теле цикла. Следующее поколение анализатора, Polonius, решает именно этот класс случаев, оно доступно в ночных сборках флагом -Zpolonius и в stable пока не включено. Редакция 2024, пришедшая с Rust 1.85 в феврале 2025 года, ужесточила другую грань: взятие ссылки на static mut превратилось в ошибку по умолчанию, для изменяемого глобального состояния теперь нужны обёртки вроде Mutex. Направление видно сразу: послабления доказываются годами, ужесточения закрывают реальные дыры, случайных послаблений не бывает.

Обходные пути в реальном коде от mem take до Rc и RefCell

Первый навык зрелого кода: функции возвращают владение, а не раздают ссылки на свои внутренности:

fn longest_word(s: &str) -> String {
    s.split_whitespace()
        .max_by_key(|w| w.len())
        .unwrap_or_default()
        .to_string()
}

Второй приём выручает, когда заимствованное значение нужно заменить целиком: std::mem::take забирает содержимое и оставляет на месте значение по умолчанию:

use std::mem;

fn drain(s: &mut String) -> String {
    mem::take(s)
}

fn main() {
    let mut s = String::from("кэш");
    let taken = drain(&mut s);
    println!("{} и пустое поле: [{}]", taken, s);
}

Третий приём: раздельные заимствования полей структуры. Компилятор видит, что name и email не пересекаются, и разрешает одновременное чтение одного поля с записью в другое:

struct User {
    name: String,
    email: String,
}

fn main() {
    let mut u = User {
        name: "Анна".to_string(),
        email: "Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в браузере должен быть включен Javascript.".to_string(),
    };
    let name_ref = &u.name;
    u.email.clear();
    println!("{} {}", name_ref, u.email);
}

Когда владельцев действительно нужно несколько, приходит Rc со счётчиком ссылок: значение освобождается после ухода последнего владельца. Изменяемость внутри разделяемого значения даёт RefCell, он переносит проверку заимствований с компиляции на исполнение:

use std::cell::RefCell;
use std::rc::Rc;

fn main() {
    let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
    let mirror = Rc::clone(&shared);
    mirror.borrow_mut().push(4);
    println!("{:?}", shared.borrow());
}

Нарушение правил в RefCell превращается в панику вида already borrowed, ошибка не исчезает, а меняет стадию с компиляции на исполнение. Для многопоточности связка меняется на Arc<Mutex<T>>, а маркерные трейты Send и Sync не пускают Rc в другой поток, сборка падает с error[E0277] ещё до запуска. Самоссылающиеся структуры вроде узла, который держит и данные, и указатель на соседний узел, в безопасном коде не выражаются, для них есть обёртки из крейтов self_cell и ouroboros либо явный unsafe с ответственностью на авторе.

Гарантии безопасности памяти и плата за них без сборщика мусора

Сухой остаток всей строгости звучит скромно и стоит дорого: в безопасном коде невозможны висячие ссылки, чтение освобождённой памяти, двойное освобождение, разыменование нулевого указателя и гонки данных. Нулевого указателя в языке нет вовсе, его роль играет Option<T> с обязательной проверкой перед использованием. Память освобождается детерминированно, при выходе владельца из области видимости, без фаз сборки мусора и внезапных пауз на куче, отсюда востребованность во встроенных системах, драйверах и браузерном движке. Ядро Linux приняло базовую инфраструктуру Rust в версии 6.1 в конце 2022 года, Android получает компоненты на Rust начиная с версии 12, Windows переводит на язык часть внутренних модулей. Опросы разработчиков год за годом закрепляют за Rust первое место по доле желающих вернуться к нему, около 80 процентов респондентов. Парадокс очевиден: самая болезненная часть языка оказалась и причиной такой лояльности, ведь программы годами работают без целого класса аварийных остановок.

Сколько времени уходит на привыкание к правилам заимствований

Честный ответ без утайки: первые дни тяжелы, вторая неделя приносит первые победы, через месяц характерные ошибки проходят сами. Траектория у большинства одинаковая. Сначала программист ставит clone() на каждое сомнительное место и тихо злится на компилятор. Потом замечает, что компилятор ругается ровно там, где в старых языках пряталась бы гонка, утечка или чтение битой памяти. К концу первого месяца привычки переворачиваются: код пишется сразу так, что компилятору нравится, потому что такой код и человеку понятнее. Borrow checker в роли строгого ревьюера приучает убирать лишнее разделяемое состояние, и половина его жалоб после переписывания оказывается находкой в дизайне. Практические привычки, которые сокращают период ломки, просты. Ограничивать время жизни ссылки блоком фигурных скобок, а не всей функцией. Возвращать из функций владельца, а не ссылку на локальную переменную. Хранить в структурах индексы вместо ссылок на элементы Vec. Разносить заимствования полей вместо захвата всей структуры. Читать официальную книгу языка и решать упражнения из сборника rustlings. И главный вопрос при конфликте не как обойти проверку, а зачем коду понадобилось одновременно читать и менять одно место. Чаще всего ответ на него переписывает дизайн к лучшему.

Borrow checker не налог на разработку, а обмен с фиксированной ценой: примерно месяц трения на старте в обмен на отсутствие целого класса ошибок на всём сроке жизни программы. Коды E0502, E0382 и E0506 пугают только до первого осознания, что каждый из них описывает место, где другой язык молча продал бы битую память и ждал бы аварийного завершения. Правила заимствований осваиваются не годами, а неделями практики, а дальше экономия идёт по всем веткам: меньше ночной отладки, меньше аварийных релизов, больше уверенности в коде, который пережил проверку компилятором.