У времени жизни в 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("")
}
Компилятор сам связал выходное время с входным, потому что вариант связи единственный. Автосокращение опирается на три правила:
- каждая входная ссылка получает собственное время жизни;
- если входная ссылка одна, выходные наследуют её время;
- у методов выходные ссылки наследуют время ссылки 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 читаются как обычные сообщения о несовпадении сроков, а апострофы в сигнатурах перестают быть пустыми значками и становятся подписями под честными контрактами.