У времени жизни в Rust репутация самой непонятной темы языка. Новичок видит апострофы в сигнатурах и решает, что без зубрёжки тут не разобраться. Разработчик с опытом пишет аннотации по наитию, а когда сборка падает, добавляет апострофы наугад, пока ошибка не исчезнет. Оба идут не с той стороны: время жизни это не список правил для заучивания, а отношения между заимствованиями, которые компилятор вычисляет сам почти всегда. Явные аннотации нужны только там, где отношений несколько и выбор неоднозначен. Разберём рабочую модель: что именно проверяет компилятор, когда автосокращение спасает, где явные параметры легальны и неизбежны, и как читать ошибки E0597 без ощущения, что вас несправедливо огрызнули.

Что время жизни ссылки проверяет в реальности и что стирается при компиляции

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

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

let name: &'static str = "report";

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

Путаницу добавляет двойное чтение слова в ограничениях обобщений. Связка T: 'static означает не вечную жизнь значения, а отсутствие внутри типа коротких заимствований. Тип, который владеет всеми своими данными, удовлетворяет ограничению, сколько бы он ни прожил:

fn print_all<T: std::fmt::Display + 'static>(items: Vec<T>) {
    for item in items {
        println!("{item}");
    }
}

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

Автосокращение аннотаций и три правила покрывающие большинство сигнатур

Начнём с хорошей новости: в большинстве функций аннотаций нет вообще, и код при этом корректен.

fn first_word(text: &str) -> &str {
    text.split_whitespace().next().unwrap_or("")
}

Компилятор сам связал выходное время с входным, потому что вариант связи единственный. Автосокращение опирается на три правила:

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

С правилами на виду метод почти не требует текста:

struct Buffer {
    items: Vec<String>,
}

impl Buffer {
    fn words(&self) -> &[String] {
        &self.items
    }
}

Девять сигнатур из десяти укладываются в эти правила, и апострофы в них не появляются. Тревожиться стоит, когда правило неприменимо, а именно когда у функции несколько входных ссылок и выходная ссылка тоже есть.

Функция longest и первый запрос компилятора на явное время жизни

Хрестоматийный пример с двумя входами:

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

Ошибка E0106, missing lifetime specifier: результат пришёл из x или из y, и компилятор не решает за автора, чьё время ему назначить. Честный ответ объявляет отношение: результат валиден, пока валидны оба аргумента.

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

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

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

Мало кто проговаривает вслух ещё одну сторону: у одной функции бывает несколько честных сигнатур. Можно обещать возвращать только первый аргумент:

fn first_argument<'a, 'b>(x: &'a str, _y: &'b str) -> &'a str {
    x
}

Обе сигнатуры корректны, но контракты у них разные, и выбор между ними это проектное решение, а не формальность. Когда аннотация выбирается осознанно и становится подписью под контрактом, споры с компилятором сходят на нет.

Структуры с заимствованными полями и граница между владением и ссылкой

Второй законный повод встретить апострофы: структура, поля которой заимствуют внешние данные.

struct Excerpt<'a> {
    part: &'a str,
}

impl<'a> Excerpt<'a> {
    fn highlight(&self) -> &str {
        self.part
    }
}

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

struct Parser<'src> {
    input: &'src str,
    pos: usize,
}

Парсер ходит по чужому буферу, ни одна строка не копируется, а по завершении работы экземпляр обязан уйти раньше исходника. Обратная сторона известна: параметр расползается по всем структурам, которые держат Parser внутри, и по всем функциям, которые его передают. Функция, возвращающая Parser<'src> из &'src str, обязана объявить оба, структура с полем Parser<'src> получает ещё один, и к третьему уровню вложенности сигнатуры напоминают телеграммы из апострофов.

Рабочее правило от противного: долгоживущие структуры владеют данными. String вместо &str в конфигурации, Vec вместо среза в кэше, PathBuf вместо &Path в поле. Заимствованные поля оставляют короткоживущим представлениям, которые создаются на границе вызова и умирают внутри него. Ссылочные поля в долгоживущих объектах почти всегда ошибка дизайна, и расплата приходит аннотациями по всему проекту.

Вариантность границы outlives и запись for<'a> в сигнатурах библиотек

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

fn extend<'a, 'b>(target: &'a mut &'b str, source: &'static str) {
    *target = source;
}

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

fn extend_fixed<'a>(target: &'a mut &'static str, source: &'static str) {
    *target = source;
}

Вторая конструкция, граница outlives. Запись 'long: 'short читается как первое время покрывает второе и встречается там, где вложенность областей нужно проговорить явно:

fn coalesce<'short, 'long: 'short>(
    brief: &'short str,
    full: &'long str,
) -> &'short str {
    brief
}

Третья, границы высшего ранга. Запись for<'a> означает: функция или замыкание обязано работать с любым временем жизни, какое ему подсунут, а не с одним фиксированным:

fn measure<F>(f: F) -> usize
where
    F: for<'a> Fn(&'a str) -> usize,
{
    f("sample")
}

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

Ошибки E0597 и E0502 плюс история NLL от лексических областей

Разговор о временах был бы хуже, не знай мы, откуда взялись нынешние сообщения. Классика жанра:

let r;
{
    let value = 42;
    r = &value;
}
println!("{r}");

Ошибка E0597, borrowed value does not live long enough: value уходит в конце блока, а печать происходит позже. Компилятор не придирается, он сообщает ровно то, что предсказывает модель: заимствование пережило значение. Соседний код ошибки E0502 описывает конфликт изменяемого и неизменяемого заимствований в одной точке.

Родственный частый случай, E0716, ловит промежуточные значения, освобождённые в конце того же выражения:

let trimmed: &str = &String::from("  отчёт  ").trim();

Временная строка создана и освобождена в одной строке, а заимствование из неё планировалось жить дольше. Починка переносит владельца в переменную:

let text = String::from("  отчёт  ");
let trimmed = text.trim();

Сообщения стали заметно человечнее после появления NLL, non-lexical lifetimes. До Rust 1.31 заимствование держалось до конца лексической области, до закрывающей скобки, даже если ссылкой уже никто не пользовался. Такой код не собирался годами:

let mut x = 5;
let y = &x;
let z = &mut x;

NLL вычисляет область по последнему реальному использованию: y после третьей строки не читается, значит изменяемое заимствование легально. В Rust 1.31, декабрь 2018, поведение стало умолчанием для редакции 2018, а Rust 1.36, июль 2019, распространил его на редакцию 2015. Преемник идеи, модель Polonius, разрабатывался как ещё более точная, принимающая программы, которые NLL бракует зря, и остаётся исследовательским направлением. Практический вывод: сообщения ошибок стоит читать целиком, компилятор называет срок, точку и причину, и чаще всего предлагает поправку, которую стоило бы принять.

Приёмы упрощения от индексов арен до move в асинхронных задачах

Когда аннотации расползаются, вопрос не в символах, а в владельце данных. Первое средство, индексы вместо ссылок в связанных структурах:

struct Node {
    parent: Option<usize>,
    children: Vec<usize>,
}

Граф живёт в Vec, узлы ссылаются друг на друга числами, апострофы исчезают целиком. Арену, единый владелец для однородных значений, строят ровно так.

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

use std::borrow::Cow;

fn normalize<'a>(input: &'a str) -> Cow<'a, str> {
    if input.contains(' ') {
        Cow::Owned(input.replace(' ', "_"))
    } else {
        Cow::Borrowed(input)
    }
}

Третье средство, дизайн сигнатур: принимать &str вместо &String и срез &[T] вместо &Vec<T>, из долгих операций возвращать владеющие значения. Слабая сигнатура с &String вынуждает вызывающего жонглировать ссылками, сильная берёт срез и не создаёт поводов для аннотаций вовсе:

fn render(title: &str) -> String {
    format!("<h1>{title}</h1>")
}

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

let task_name = String::from("report-builder");
tokio::spawn(async move {
    build_report(&task_name);
});

Ключевое слово move здесь прямое следствие модели, а не причуда фреймворка: срок жизни задачи неизвестен, значит всё, что ей нужно, обязано жить само по себе.

Время жизни в Rust сдаётся не зубрёжкой, а моделью: заимствования не переживают значений, компилятор вычисляет области по использованию, аннотации лишь разрешают неоднозначность и фиксируют контракт. Автосокращение закрывает большинство сигнатур, явные параметры появляются у функций с несколькими входами и у структур с заимствованными полями, вариантность и границы высшего ранга остаются инструментами авторов библиотек. Громоздкие аннотации почти всегда сигнал перегруженного дизайна, и правильный ход обычно не в подборе символов, а в смене владельца данных. Когда модель встала на место, ошибки E0597 читаются как обычные сообщения о несовпадении сроков, а апострофы в сигнатурах перестают быть пустыми значками и становятся подписями под честными контрактами.