Раздел 23 · Rust

Заимствование, ссылки и срезы

middle~35 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Заимствование, ссылки и срезы

Move решает, кто владеет, заимствование решает, кто пользуется. &T и &mut T, правила borrow checker, срезы строк и массивов. Главная мысль: висячих ссылок в безопасном Rust не существует по построению, и это не проверка в рантайме, а доказательство на этапе компиляции.

Идея

Прошлый урок закончился неудобством: чтобы функция просто посмотрела на строку, ей пришлось отдать владение целиком и попросить вернуть. Туда кортеж, обратно кортеж. Модель владения честная, но жить так нельзя, и Rust это знает.

Недостающая деталь называется заимствованием. Метафора точная, как в библиотеке: ты берёшь книгу почитать, но книга не твоя, и вернуть её придётся раньше, чем библиотека закроется. Владелец остаётся владельцем, drop остаётся за ним, а ссылка это просто временный пропуск к данным.

Правил два, и весь урок это их следствия:

  1. Ссылка не живёт дольше владельца. Заём кончается раньше, чем умирает значение.
  2. В каждый момент либо сколько угодно разделяемых ссылок &T, либо ровно одна эксклюзивная &mut T. Третьего не дано.

Первое правило убивает висячие ссылки как класс. Второе на первый взгляд кажется бюрократией, но присмотрись: оно запрещает ровно одну вещь, одновременные чтение и запись одного значения. Сколько угодно читателей безопасно: никто ничего не меняет. Один писатель без читателей безопасен: некому увидеть значение наполовину обновлённым. А вот читатель рядом с писателем это уже наблюдение чужой незаконченной работы, и именно из этой ситуации растут самые подлые баги, от инвалидации итераторов до гонок данных в многопоточке.

Разделяемые и эксклюзивные ссылки

Ссылка создаётся оператором &, и функция из прошлого урока наконец-то получает нормальную сигнатуру:

fn describe(report: &String) -> usize {
    report.len()
}

fn main() {
    let report = String::from("итоги квартала");
    let length = describe(&report);   // одолжили, не отдали
    println!("{report}: {length}");   // строка всё ещё наша
}

Обрати внимание на обе стороны: в сигнатуре &String говорит «мне только посмотреть», а на месте вызова &report подтверждает «одалживаю». Передача ссылки видна глазами и там, и там, никакой магии за спиной. В памяти ссылка это просто адрес, одно машинное слово, копировать её дёшево.

Разделяемая ссылка читает, но не меняет. Для изменений нужна эксклюзивная:

fn shout(text: &mut String) {
    text.push_str("!!!");
}

fn main() {
    let mut line = String::from("вперёд");
    shout(&mut line);                 // одолжили с правом записи
    println!("{line}");               // вперёд!!!
}

Здесь mut встречается дважды, и это разные mut: let mut line разрешает менять значение в принципе, &mut line выдаёт одну эксклюзивную ссылку с правом записи. Неизменяемое связывание эксклюзивно одолжить нельзя, компилятор потребует сначала признать значение изменяемым.

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

let mut total = 0;
let counter = &mut total;
*counter += 10;                       // пишем в значение за ссылкой

Но в большинстве кода * ты не увидишь: при вызове метода Rust сам подставляет ссылку нужного вида. Запись line.len() это сахар для str::len(&line), а line.push_str(...) сам берёт &mut line. Поэтому методы вызываются одинаково и у значения, и у ссылки на него, и код не зарастает звёздочками.

Теперь главное: что будет, если попробовать нарушить правило одной из двух. Возьмём Vec, динамический массив, с которым подробно познакомимся через три урока:

let mut scores = vec![10, 20, 30];
let first = &scores[0];               // разделяемый заём элемента
scores.push(40);                      // ошибка: нужен &mut, а жив &
println!("первый: {first}");
error[E0502]: cannot borrow `scores` as mutable
              because it is also borrowed as immutable

Компилятор отказал, и стоит понять, от чего именно он нас спас. push в заполненный вектор выделяет новый буфер побольше, переносит элементы и освобождает старый. Ссылка first указывала в старый буфер: одна строчка, и она висит над освобождённой памятью. В C++ это знаменитая инвалидация итераторов, баг, который воспроизводится раз в тысячу запусков, когда буфер совпадёт с границей ёмкости. В Rust эта программа не существует: писатель не получит доступ, пока жив читатель.

Ошибки заимствования стоит читать целиком, они устроены как мини-разбор: первая стрелка показывает, где заём начался, вторая, где он помешал, третья, где одолженное значение ещё используется. Ответ «что переставить» почти всегда написан прямо в сообщении.

И обещанный мост вперёд: правило одной из двух это то же самое, что запрет гонок данных, только внутри одного потока. В блоке про многопоточность ты увидишь, что Rust гарантирует отсутствие гонок тем же механизмом: один писатель или много читателей, проверено компилятором. Правило, которое сегодня кажется придиркой, в многопоточке окажется суперсилой.

Borrow checker и NLL

Часть компилятора, которая следит за займами, называется borrow checker. Чтобы с ним не воевать, нужно знать одну вещь про то, как он считает время жизни займа.

Заём кончается на последнем использовании ссылки, а не на закрывающей скобке. Сравни с примером выше:

let mut scores = vec![10, 20, 30];
let first = &scores[0];
println!("первый: {first}");          // последнее использование займа
scores.push(40);                      // можно: заём уже мёртв

Та же пара «читатель и писатель», но конфликта нет: к моменту push ссылку first больше никто не трогает, и компилятор закрыл заём ещё на строке с println!. Эта точность называется non-lexical lifetimes, и она в языке с 2018 года. Если встретишь старую статью, где автор героически оборачивает каждый заём в фигурные скобки, чтобы «убить ссылку пораньше», знай: это археология, сейчас компилятор делает это сам.

Практическое следствие: конфликт займов чаще всего лечится перестановкой строк. Сначала всё, что читает, потом всё, что пишет. Если перестановка не помогает, это сигнал посмотреть на структуру кода: возможно, два дела, читающее и пишущее, склеены в одно и просятся в разные функции.

Срезы

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

Для строк срез пишется &str, и ты его уже видел: строковые литералы из первых уроков имеют именно этот тип, это срезы, указывающие на байты, вшитые прямо в бинарь. Срез строки в куче берётся через диапазон:

fn first_word(line: &str) -> &str {
    for (i, byte) in line.bytes().enumerate() {
        if byte == b' ' {
            return &line[..i];        // срез от начала до пробела
        }
    }
    line                              // пробела нет: всё одно слово
}

fn main() {
    let line = String::from("первое слово важнее");
    let word = first_word(&line);
    println!("{word}");               // первое
}

Здесь .bytes() перебирает байты строки, b' ' это байтовый литерал пробела, а &line[..i] берёт срез от начала до позиции i. Индексы срезов считаются в байтах, и с юникодом тут есть тонкости, которые мы разберём в уроке про коллекции и строки; пробел в ASCII, поэтому наш срез всегда режет по честной границе. Заметь главное: first_word не аллоцирует и не копирует ни байта. Она возвращает окно в строку владельца, и borrow checker гарантирует, что окно не переживёт строку.

У массивов и векторов срез выглядит как &[T]:

let temps = [21, 19, 23, 25, 24, 20, 18];
let weekend = &temps[5..];            // последние два дня

fn sum(values: &[i32]) -> i32 {
    let mut total = 0;
    for v in values {
        total += v;
    }
    total
}

Функция sum принимает &[i32] и поэтому всеядна: ей можно скормить срез массива, целый массив, кусок вектора, весь вектор. Указатель плюс длина, остальное её не волнует.

Это и есть ответ на вопрос, который задаёт каждый новичок: почему сигнатуры принимают &str, а не &String?

СигнатураЧто принимает
fn f(s: &String)только ссылку на String
fn f(s: &str)&String, литерал, срез другой строки

&String это ссылка строго на одну конкретную обёртку. &str это любой участок строковых данных, где бы они ни жили: в куче у String, в бинаре у литерала, внутри чужой строки. Компилятор сам превращает &String в &str на месте вызова, механизм называется deref coercion, и мы разберём его в уроке про стандартные трейты. Правило на сегодня простое: в параметрах функций пиши &str и &[T], владелец нужен в сигнатуре только тому, кто собирается владеть. Функция describe(report: &String) из начала урока была учебной: в боевом коде она принимала бы &str.

Висячих ссылок не существует

Закроем урок обещанием из лида. Попробуем сделать худшее, что можно сделать со ссылкой: вернуть её на значение, которое сейчас умрёт.

fn make_greeting() -> &String {
    let s = String::from("привет");
    &s
}                                     // drop(s): владелец умирает здесь
error[E0106]: missing lifetime specifier
  = help: this function's return type contains a borrowed value,
          but there is no value for it to be borrowed from

Компилятор говорит буквально следующее: ты возвращаешь заём, но во всей программе нет значения, у которого его можно было бы взять. Строка s умрёт на закрывающей скобке, и ссылка на неё была бы пропуском в освобождённую память. Программа не компилируется, висячая ссылка не родилась.

Теперь сравни с C, где ровно этот код собирается:

char *make_greeting(void) {
    char buffer[] = "привет";
    return buffer;                    // компилируется, иногда даже с warning
}

Дальше классика жанра: указатель смотрит в отработанный стековый кадр, программа какое-то время «работает», потому что память ещё не перезаписана, а потом следующий вызов функции затирает кадр, и где-нибудь далеко от места преступления данные превращаются в мусор. Баг воспроизводится через раз и отлаживается днями.

Разница не в строгости ради строгости. Первое правило заимствования, ссылка не живёт дольше владельца, это утверждение, которое компилятор доказывает для каждой ссылки в программе. Иногда для доказательства ему нужны подсказки, те самые lifetime-аннотации из сообщения об ошибке: с ними мы встретимся в блоке про глубокое ядро языка. А правильный фикс для make_greeting ты уже знаешь из прошлого урока: верни String целиком, передай владение наружу.

Практика

Срезы и займы усваиваются только через сопротивление компилятора. Пять задач по нарастающей: от окон в чужие данные до лайфтайм-аннотаций, про которые мы пока сказали полслова.

ДЗ

Дальше

Модель владения теперь полная: move передаёт насовсем, &T одалживает почитать, &mut T одалживает с правом записи, срезы одалживают кусками, и всё это проверено до запуска. Пора строить из этого что-то своё. В уроке 05 появляются структуры и методы: собираем данные в типы, вешаем на них поведение и смотрим, как три вида self продолжают историю владения.