Раздел 23 · Rust

Лайфтаймы вглубь

senior~40 мин

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

Лайфтаймы вглубь

Прошлый урок собрал связный список тремя способами, и на каждом из них borrow checker спорил с тобой по-своему. Сегодня разбираемся, как именно он рассуждает. Лайфтаймы это не аннотации ради синтаксиса, а имена для регионов кода, по которым компилятор строит граф ограничений и решает, можно ли пропустить ссылку. Разберём регион вместо времени, подтипизацию, elision, что такое 'static на самом деле и почему возврат ссылки наружу так часто упирается в красную ошибку.

Три примера, которые удивляют

Начнём с кода, который на глаз выглядит корректным, а компилятор его отвергает. Или наоборот, выглядит подозрительным, а проходит. Если интуиция спотыкается на этих трёх, значит модель лайфтаймов в голове неточная, и урок как раз про неё.

Первый. Функция, которая вроде бы просто возвращает большую из двух строк:

fn longest(x: &str, y: &str) -> &str { // не скомпилируется
    if x.len() > y.len() { x } else { y }
}

Компилятор требует лайфтайм, хотя человек прочитает функцию без запинки. Почему ему мало?

Второй. Заём, который мешает там, где не должен бы. До определённой версии Rust такой код не компилировался, а теперь да:

let mut v = vec![1, 2, 3];
let first = &v[0];
println!("{first}");
v.push(4); // когда-то здесь была ошибка, сейчас всё чисто

Что изменилось? Код-то прежний.

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

let outer;
{
    let value = String::from("привет");
    outer = &value; // а вот это уже не скомпилируется
}
println!("{outer}");

Чтобы все три случая встали на место, нужна одна правильная картинка лайфтайма. Соберём её.

Лайфтайм это регион, а не время

Слово «время жизни» сбивает с толку: кажется, что речь про длительность в секундах. На самом деле лайфтайм это множество точек программы, на котором ссылка должна оставаться валидной. Не «сколько секунд живёт», а «на каких строках кода она ещё может понадобиться». Регион, а не таймер.

Эта подмена картинки решает почти всё. Возьмём первый пример из тройки и посмотрим на него как на регионы.

Открой вкладку «регион, не время». Значение s это серая полоса: оно живёт от рождения до конца блока. Ссылка r это цветная полоса покороче: её регион это только те строки, где она ещё читается. Главное ограничение, которое держит в уме компилятор: источник обязан покрывать весь регион ссылки. Значение s покрывает регион r, значит ссылка валидна. Всё.

Из этой картинки сразу следует, почему третий пример падает: там ссылка outer хочет жить после println, а её источник value умирает на закрывающей скобке блока. Регион ссылки торчит за пределы региона источника, ограничение нарушено, компилятор отказывает. Никакой магии, просто две полосы, и одна длиннее другой там, где нельзя.

NLL: регион кончается на последнем использовании

Теперь второй пример, тот самый, который раньше не компилировался, а теперь да. Переключи виджет на вкладку «NLL: заём кончается рано» и потыкай тумблер сверху.

Раньше borrow checker был лексическим: регион заёма тянулся до закрывающей скобки блока, где ссылка объявлена, даже если по факту её больше нигде не трогают. Это создавало уйму ложных конфликтов. В примере общий заём first доживал до конца блока и пересекался с v.push(4), которому нужен &mut v. Две пересекающиеся полосы, одна изменяющая, вот и ошибка, хотя код безупречен.

В 2018 году ввели NLL, нелексические лайфтаймы. Теперь регион заёма обрывается на последнем фактическом использовании ссылки. В примере first читается последний раз в println, дальше её полоса заканчивается, и к v.push(4) живых общих заёмов уже нет. Полосы не пересекаются, код проходит.

Практический вывод: borrow checker умнее, чем кажется по скобкам. Если ссылка дальше не используется, её заём уже отпущен, даже если переменная формально ещё в области видимости. Тумблер в виджете показывает обе картинки рядом: до NLL полоса first дотянута до строки с push и закрашена в опасный цвет, после NLL она кончается на последнем чтении.

Подтипизация: долгий лайфтайм подходит туда, где ждут короткий

Регионы можно сравнивать. Один регион содержит другой, как множество точек. Отсюда растёт подтипизация лайфтаймов: если регион 'a целиком накрывает регион 'b, то 'a это подтип 'b, и ссылку с лайфтаймом 'a можно подставить туда, где ждут 'b. Записывают это как 'a: 'b и читают «'a живёт хотя бы столько же, сколько 'b», по-английски outlives.

Интуиция: долгоживущая ссылка годится везде, где хватило бы короткоживущей. Если что-то валидно весь год, оно тем более валидно в марте. Обратное неверно: мартовскую ссылку нельзя выдать за годовую.

Вкладка «подтипизация ‘a: ‘b» показывает это на полосах. Значение long живёт до самого конца, ссылка pick родилась во вложенном блоке, но указывает на long. Когда r забирает значение pick, требуется лайфтайм покороче, до println. Лайфтайм long его покрывает, подстановка проходит. Блок ограничивает только саму переменную pick, а не регион источника.

Эта же подтипизация объясняет, почему longest из первого примера требует аннотацию. У функции два входа, x и y, и каждый по умолчанию получает свой регион. Возвращаемая ссылка это то один вход, то другой, и компилятор не знает, каким из двух регионов её ограничить. Надо сказать ему явно, что выход живёт не дольше, чем оба входа:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

Здесь 'a это не «настоящий лайфтайм одного из аргументов», а пересечение: при вызове компилятор подставит вместо 'a самый короткий регион, на котором валидны оба входа, и потребует, чтобы результат не пережил его. Благодаря подтипизации более долгоживущий аргумент спокойно сужается до этого общего региона.

Чему именно эти полосы подчиняются на уровне типов, ковариантности и контравариантности, посвящён следующий урок про вариантность. Пока достаточно интуиции «долгое подставляется вместо короткого».

Elision: правила, которые пишут лайфтаймы за тебя

Если бы лайфтайм приходилось писать в каждой сигнатуре, код утонул бы в 'a. Поэтому компилятор выводит их сам по трём механическим правилам. Это и есть elision, опускание лайфтаймов. Важно: это не умный вывод, а три жёстких правила, применяемых по очереди.

  1. Каждой опущенной ссылке во входной позиции компилятор даёт свой отдельный лайфтайм. У fn f(x: &i32, y: &i32) входные лайфтаймы разные, как fn f<'a, 'b>(x: &'a i32, y: &'b i32).
  2. Если входной лайфтайм ровно один, его назначают всем опущенным лайфтаймам на выходе. Поэтому fn first(s: &str) -> &str работает без аннотаций: выход привязан к единственному входу.
  3. Если входов несколько, но один из них это &self или &mut self, лайфтайм self назначают всем опущенным выходам. Это правило про методы: возвращённая из метода ссылка по умолчанию живёт столько же, сколько self.

Прогони эти правила на longest. Входов два, оба ссылки, значит правило 1 даёт им разные лайфтаймы. Правило 2 не сработало: вход не один. Правило 3 не сработало: нет self. Выход остался без лайфтайма, и компилятор честно требует написать его руками. Именно поэтому longest нуждается в аннотации, а first нет: не «компилятор поленился», а «правил не хватило».

struct Parser<'a> {
    input: &'a str,
}

impl<'a> Parser<'a> {
    // лайфтайм выхода опущен, но правило 3 берёт его от &self
    fn rest(&self) -> &str {
        self.input
    }
}

Здесь rest возвращает ссылку без аннотаций, потому что правило 3 привязало выход к &self. Если бы тебе понадобилось вернуть ссылку с лайфтаймом 'a самих данных, а не self, пришлось бы написать его явно: fn rest(&self) -> &'a str.

'static: не «вечно», а «может жить сколько угодно»

'static это самый недопонятый лайфтайм. Кажется, что он означает «живёт вечно», и от этого рождаются неправильные решения. На самом деле у 'static два разных значения, и путать их вредно.

Первое значение это ссылка &'static T: данные, валидные на протяжении всей программы. Классика это строковые литералы, они зашиты в бинарник: let s: &'static str = "литерал";. Сюда же Box::leak, который намеренно «забывает» значение в куче, чтобы получить на него вечную ссылку.

Второе значение это граница T: 'static, и вот тут ловушка. Запись T: 'static не значит «значение живёт вечно». Она значит «тип T не содержит ссылок короче, чем 'static», то есть либо владеет всеми своими данными, либо хранит только 'static-ссылки. Такое значение может прожить сколько угодно долго, потому что не зависит от чужого заёма, но вовсе не обязано.

T: 'static постоянно встречается там, где значение надо «отвязать» от текущего стека: отправить в поток, положить в коллекцию, которая переживёт функцию, вернуть из замыкания. String удовлетворяет 'static, хотя ты дропаешь её через две строки. Удовлетворяет, потому что не держит чужих заёмов, а не потому, что бессмертна.

fn needs_static<T: 'static>(_value: T) {}

fn main() {
    let owned = String::from("я владею собой"); // владеет данными
    needs_static(owned); // ок: String: 'static, хотя дропнется в конце main

    let n = 42;
    let r = &n; // &i32 с коротким лайфтаймом
    // needs_static(r); // ошибка: &i32 живёт не дольше n, это не 'static
}

Запомни формулу: &'static это про «ссылка валидна всю программу», а T: 'static это про «тип не одолжен ни у кого на время». Разные вещи под одним именем.

Borrow checker как граф ограничений

Соберём механику воедино. Откуда borrow checker берёт свои «можно» и «нельзя»? Он не угадывает, он решает систему ограничений.

На входе у него регионы: каждой ссылке, каждому заёму, каждому лайфтайм-параметру соответствует свой регион, пока неизвестный. Дальше компилятор собирает граф ограничений: набор условий вида 'a: 'b (этот регион обязан покрывать тот) плюс условия живости (на каких точках программы ссылка ещё нужна). Источники ограничений ровно те, что мы разобрали: подтипизация при подстановке ссылок, требование «источник переживает заём», правила elision, аннотации в сигнатурах.

Потом он ищет регионы, удовлетворяющие всем условиям сразу. Нашлись, код проходит. Не нашлись, например ссылка обязана пережить свой источник или две изменяющие полосы пересеклись, выводится ошибка лайфтайма. Виджет ровно про это: строка «ограничение» внизу показывает условие, а пометка ✓ выполнимо или ✗ невыполнимо это и есть вердикт решателя.

Понимать borrow checker как решатель полезно вот чем: ошибка лайфтайма это не «компилятор тебя не понял», а «твои ограничения противоречивы». Чинят её не случайным расставлением 'a, а тем, чтобы сделать систему выполнимой: укоротить регион ссылки (отпустить заём раньше), удлинить регион источника (вынести значение наружу, дать ему владение), либо признать, что ссылка тут вообще не нужна, и взять значение по владению.

Лайфтаймы в структурах: заём, спрятанный в поле

Пока ссылки жили в локальных переменных, лайфтаймы выводились почти всегда. Стоит положить ссылку в поле структуры, и лайфтайм придётся назвать руками. Логика та же: структура, которая хранит чужой заём, не имеет права пережить того, у кого одолжила.

struct Excerpt<'a> {
    part: &'a str, // структура держит заём чужой строки
}

fn main() {
    let novel = String::from("Зовите меня Измаил. Несколько лет назад...");
    let first_sentence = novel.split('.').next().unwrap();
    let excerpt = Excerpt { part: first_sentence };
    println!("{}", excerpt.part);
} // excerpt дропается раньше novel, лайфтайм соблюдён

Excerpt<'a> читается так: «экземпляр живёт не дольше региона 'a, из которого взята строка». Параметр 'a это не свойство структуры, а связь между ней и источником данных. Если попробуешь сделать так, чтобы Excerpt пережил novel, получишь ту же ошибку, что и с висячей ссылкой: borrow checker увидит, что регион поля торчит за регион источника.

Отсюда практическое правило проектирования: структура со ссылкой это структура-обёртка над чужими данными, она удобна для парсеров, итераторов, представлений-срезов, всего, что живёт коротко и рядом с источником. Если же типу надо жить долго, путешествовать по потокам или лежать в долгой коллекции, не давай ему ссылок, дай ему владение: String вместо &str, Vec<T> вместо &[T]. Это тот самый выбор между заёмом и владением из урока про ссылки и срезы, только теперь он зашит в сигнатуру типа.

Ловушка номер один: возврат ссылки наружу

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

fn dangle() -> &String { // E0106: missing lifetime specifier
    let s = String::from("умру в конце функции");
    &s
}

Компилятор отвечает двухступенчато. Сначала E0106: ты не указал лайфтайм, а вывести его не из чего, у функции нет входных ссылок, чтобы привязать выход по правилам elision. Если попробуешь дописать 'static или любой 'a, выйдет вторая, настоящая ошибка, E0515: cannot return reference to local variable s. Полоса источника s кончается на закрывающей скобке, а результат обязан её пережить. Ограничение 'ret: 's невыполнимо.

Лечение всегда одно из двух. Либо верни владение вместо ссылки, пусть значение уедет к вызывающему целиком:

fn no_dangle() -> String {
    String::from("теперь это твоё") // отдаём значение по владению, не ссылку
}

Либо, если ссылку отдать действительно надо, она должна указывать на чужие входные данные, а не на локальные:

fn prefix<'a>(text: &'a str) -> &'a str {
    &text[..text.len().min(8)] // ссылка живёт ровно столько, сколько вход text
}

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

Правило урока

Свернём всё в одну мысль, которую стоит унести с собой.

Лайфтайм это регион кода, на котором ссылка валидна, а не отрезок времени. Borrow checker собирает регионы в граф ограничений вида «источник переживает заём» и решает его. Долгий регион подставляется вместо короткого (подтипизация), заём кончается на последнем использовании (NLL), а ссылка наружу законна, только если указывает на чужие входные данные.

Типичная ошибка, которую этот урок снимает: расставлять 'a наугад, пока компилятор не замолчит. Аннотации это не заклинание, а факты, которые ты сообщаешь решателю: какой регион чей подтип, какой выход к какому входу привязан. Если думать про них как про условия в системе, а не как про синтаксис, красные ошибки лайфтаймов перестают быть случайными.

ДЗ

Дальше

Ты разобрал, как borrow checker думает: лайфтайм это регион, аннотации это ограничения, проверка это решение системы. Осталась самая абстрактная часть фундамента, на которой держится безопасность ссылок при подстановке лайфтаймов: вариантность. Там мы поймём, почему &T ковариантен, &mut T инвариантен, и как из вариантности полей выводится вариантность всего типа, заодно встретив PhantomData. Это прямое продолжение сегодняшней подтипизации: мы узнали, что долгий лайфтайм подставляется вместо короткого, а вариантность отвечает на вопрос, когда такую подстановку можно протащить внутрь составного типа, не сломав память.