Правила заимствования и почему нельзя две &mut

Заимствование — способ дать доступ к данным, не отдавая владение; правил всего два, и держатся на них потоки, итераторы и оптимизации.

Borrowing (заимствование) — получение ссылки на значение без передачи владения: &T для чтения и &mut T для изменения.

Вопрос: «Сформулируй правила заимствования»

Вопрос-фильтр. Формулировка должна отскакивать от зубов, потому что дальше интервьюер будет строить на ней всё остальное.

Что на самом деле проверяет интервьюер

Что ты знаешь их именно как «ИЛИ», а не как список ограничений. Правильно так: в один момент времени для значения может существовать либо одна изменяемая ссылка &mut T, либо сколько угодно общих ссылок &T, но не то и другое сразу. Плюс третье, техническое: ссылка не может пережить то, на что указывает.

fn main() {
    let mut s = String::from("привет");

    let r1 = &s;
    let r2 = &s;
    println!("{} {}", r1, r2);   // сколько угодно читателей — можно

    let w = &mut s;
    w.push_str(", мир");
    println!("{}", w);           // один писатель — можно
}

Вывод:

привет привет
привет, мир

Вопрос: «Почему компилятор запрещает две изменяемые ссылки одновременно?»

Что на самом деле проверяет интервьюер

Видишь ли ты за правилом инженерный смысл, а не прихоть языка. Причин три, и хорошо назвать все.

  1. Гонки данных. Два пути записи в одно значение — это ровно определение data race, если рядом появятся потоки. Rust ловит его на однопоточном коде, поэтому многопоточный получается безопасным «бесплатно».
  2. Инвалидация. Vec при push может перевыделить буфер. Все ссылки на элементы после этого указывали бы в освобождённую память.
  3. Оптимизации. Гарантия отсутствия aliasing позволяет компилятору держать значение в регистре и не перечитывать его после каждого вызова — то, ради чего в C приходится вручную писать restrict.
fn main() {
    let mut s = String::from("привет");
    let r1 = &mut s;
    let r2 = &mut s;
    println!("{} {}", r1, r2);
}

Ошибка компиляции:

error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> src/main.rs:4:14
  |
3 |     let r1 = &mut s;
  |              ------ first mutable borrow occurs here
4 |     let r2 = &mut s;
  |              ^^^^^^ second mutable borrow occurs here
5 |     println!("{} {}", r1, r2);
  |                       -- first borrow later used here

Смешивать &T и &mut T тоже нельзя

Самый жизненный пример — изменение вектора прямо во время обхода. В Java это ConcurrentModificationException в рантайме, в C++ — молча испорченная память, в Rust — ошибка сборки:

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

Ошибка компиляции:

error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> src/main.rs:5:13
  |
3 |     for &item in &v {
  |                  --
  |                  |
  |                  immutable borrow occurs here
  |                  immutable borrow later used here
4 |         if item == 2 {
5 |             v.push(10);
  |             ^^^^^^^^^^ mutable borrow occurs here

Цикл for держит общее заимствование &v до последней итерации, а push требует изменяемого. Стандартный обход — собрать изменения в отдельный Vec и применить после цикла, либо использовать индексы и v.len() вместо итератора.

Вопрос: «Что такое NLL и почему этот код внезапно компилируется?»

fn main() {
    let mut s = String::from("привет");

    let r1 = &s;
    println!("{}", r1);      // последнее использование r1

    let r2 = &mut s;         // до NLL здесь была ошибка
    r2.push_str(", мир");
    println!("{}", r2);
}

Вывод:

привет
привет, мир

Что на самом деле проверяет интервьюер

Знаешь ли ты, что non-lexical lifetimes (NLL, включены начиная с edition 2018) изменили не правила, а способ измерять «одновременно». Раньше заимствование жило до конца лексического блока — до закрывающей фигурной скобки. Теперь оно живёт до последней точки, где ссылка реально используется. В примере выше r1 перестаёт существовать сразу после println!, поэтому &mut s ниже уже никому не мешает. Правило «одна &mut ИЛИ много &T» не изменилось — сузился интервал, на котором оно проверяется.

Код ошибкиСитуация
E0499две изменяемые ссылки на одно значение одновременно
E0502изменяемая и общая ссылка пересеклись во времени
E0505попытка переместить значение, пока оно заимствовано
E0506присваивание в значение, которое сейчас заимствовано
E0597ссылка живёт дольше, чем то, на что она указывает

Типичные ошибки кандидатов

  • Говорить «нельзя две ссылки», забывая слово «изменяемые». Общих ссылок может быть сколько угодно.
  • Считать, что NLL ослабил правила заимствования. Он поменял только границы времени жизни ссылки; гарантии те же.
  • Объяснять запрет двух &mut только через многопоточность. В однопоточном коде он тоже нужен — из-за инвалидации итераторов и перевыделения буфера.
  • Путать изменяемость переменной и изменяемость заимствования: &mut s требует, чтобы сама s была объявлена как let mut.
  • Не уметь предложить обходной путь для E0502. Ожидают вариант «накопить и применить после цикла» или «работать по индексам».

Как ответить кратко

Правила заимствования: в любой момент времени либо одна изменяемая ссылка &mut T, либо любое число общих &T, и ссылка не может пережить своё значение. Две &mut запрещены, потому что это гонка данных, инвалидация ссылок при перевыделении и потеря гарантии отсутствия aliasing для оптимизатора. NLL означает, что заимствование заканчивается на последнем использовании ссылки, а не на закрывающей скобке.

  • Коды под рукой: E0499 — две &mut, E0502 — смесь & и &mut.
  • Канонический пример: v.push() внутри for &item in &v.
  • Главная мысль: то, что в других языках падает в рантайме, здесь не проходит сборку.
Проверьте себя
1. Какая комбинация ссылок на одно значение допустима одновременно?
AОдна &mut и одна &
BДве &mut
CТри общие ссылки &
DДве &mut, если значение объявлено как let mut
2. Какую ошибку выдаст компилятор на v.push(10) внутри цикла for &item in &v?
AE0382 — borrow of moved value
BE0499 — две изменяемые ссылки
CE0502 — нельзя занять v как изменяемую, пока она заимствована как общая
DОшибки не будет, вектор просто вырастет
3. Что изменили non-lexical lifetimes (NLL)?
AРазрешили две изменяемые ссылки, если они не используются одновременно в одной строке
BЗаимствование заканчивается на последнем использовании ссылки, а не в конце блока
CОтменили необходимость указывать lifetime в сигнатурах функций
DПеренесли проверку заимствований из компилятора в рантайм