← Назад к списку тем

09. Времена жизни

Lifetime-аннотации, elision rules, structs со ссылками, HRTB (for<'a>), variance, 'static, borrow checker и NLL.

Зачем нужны lifetimes: dangling reference на этапе компиляции

В C/C++ можно вернуть указатель на уже уничтоженный локальный объект — компилятор в лучшем случае предупредит, а в худшем молча сгенерирует код, который в рантайме читает освобождённую память (use-after-free). В Go и Java эта проблема решена рантаймом: сборщик мусора (GC) не удаляет объект, пока на него есть ссылки, поэтому dangling reference физически невозможен — но ценой рантайм-издержек (паузы GC, дополнительная память под метаданные, непредсказуемая задержка).

Rust выбирает третий путь: он статически, во время компиляции, доказывает, что каждая ссылка не переживёт данные, на которые указывает — без GC и без рантайм-проверок. Механизм, который это делает — borrow checker, а lifetime-аннотации ('a, 'b, ...) — это язык, на котором вы объясняете компилятору, как связаны времена жизни ссылок в сигнатуре функции или структуре. Сам lifetime не продлевает и не изменяет время жизни данных — он лишь описывает уже существующее отношение между ссылками, чтобы компилятор мог его проверить.

fn dangling() -> &String {
    let s = String::from("hello"); // s создаётся внутри функции
    &s // ОШИБКА: cannot return reference to local variable `s`
} // s уничтожается здесь — ссылка выше указывала бы в никуда

// error[E0106]: missing lifetime specifier
// error[E0515]: cannot return value referencing local variable `s`
//
// Аналог в C: возврат указателя на переменную из стека функции —
// UB, программа может "случайно" сработать, а может упасть.
// Rust ловит это ДО запуска программы.

Правильное решение — либо вернуть владеющее значение (String вместо &String), либо принять ссылку извне и вернуть ссылку, связанную с ней через lifetime:

// Вариант 1: отдаём владение — самое простое решение
fn owned() -> String {
    String::from("hello")
}

// Вариант 2: возвращаем ссылку, "привязанную" к входному параметру
fn first_word<'a>(s: &'a str) -> &'a str {
    s.split_whitespace().next().unwrap_or("")
}
// Компилятор гарантирует: результат живёт не дольше, чем s
⚠️ Типичная ошибка компиляции: error[E0597]: `x` does not live long enough означает, что вы пытаетесь сохранить ссылку на значение (x), которое будет уничтожено раньше, чем закончится жизнь самой ссылки. Решение — либо сузить область использования ссылки, либо клонировать данные (x.clone()), либо изменить архитектуру так, чтобы данные жили достаточно долго (например, вынести их выше по стеку вызовов).

Синтаксис: lifetime-параметры функций

Lifetime-параметр — это не время в секундах, а имя для области кода, на протяжении которой ссылка остаётся валидной. Объявляется как generic-параметр в угловых скобках с апострофом: <'a>. Компилятор не «придумывает» новый lifetime — он лишь связывает уже существующие области кода одним и тем же именем и проверяет, что связь не нарушена.

// Классический пример: функция возвращает более длинную строку.
// 'a — это lifetime, ОБЩИЙ для x, y и результата.
// Компилятор поймёт: результат живёт не дольше меньшего из x и y.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let s1 = String::from("long string is long");
    let result;
    {
        let s2 = String::from("xyz");
        result = longest(s1.as_str(), s2.as_str());
        println!("{}", result); // ok: используем result, пока s2 ещё жив
    }
    // println!("{}", result); // ОШИБКА: s2 уже уничтожен,
    // а result мог бы ссылаться на s2 — компилятор не рискует
}

Важно: <'a> в сигнатуре не заставляет x и y жить одинаковое время — он требует, чтобы оба жили как минимум столько, сколько 'a, а 'a компилятор выбирает как пересечение (наименьшую) из областей жизни фактических аргументов в конкретном месте вызова.

Lifetime elision — три правила, когда можно не писать 'a

Ранний Rust требовал lifetime-аннотации почти везде, что было очень многословно. Начиная с Rust 1.0 компилятор умеет применять lifetime elision rules — три детерминированных правила, которые в частых случаях позволяют опустить явные аннотации. Если после применения всех трёх правил остаётся неоднозначность — компилятор требует аннотацию явно.

Правило 1 — каждый входной параметр-ссылка получает свой lifetime

// До elision (что компилятор "думает" неявно):
fn foo<'a, 'b>(x: &'a str, y: &'b str) { ... }

// После elision (что вы реально пишете):
fn foo(x: &str, y: &str) { ... }

Правило 2 — если входной lifetime ровно один, он присваивается всем выходным ссылкам

// До elision:
fn first_word<'a>(s: &'a str) -> &'a str { ... }

// После elision — ровно один входной параметр-ссылка,
// поэтому его lifetime автоматически "перетекает" в возврат:
fn first_word(s: &str) -> &str { ... }

Правило 3 — если среди параметров есть &self или &mut self, его lifetime присваивается всем выходным ссылкам

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

impl<'a> Parser<'a> {
    // До elision:
    // fn rest<'b>(&'b self) -> &'b str { self.input }
    // Правило 3: lifetime &self побеждает и присваивается результату
    fn rest(&self) -> &str {
        self.input
    }
}

Если правила не дают однозначного результата — например, два входных параметра-ссылки и возвращаемая ссылка без &self — компилятор не гадает, а требует явную аннотацию:

// ОШИБКА без аннотации: непонятно, от x или от y "наследует" результат
// fn ambiguous(x: &str, y: &str) -> &str { x }
// error[E0106]: missing lifetime specifier
// help: this function's return type contains a borrowed value,
// but the signature does not say whether it is borrowed from `x` or `y`

// Исправление — явно указать связь:
fn pick_first<'a>(x: &'a str, _y: &str) -> &'a str {
    x
}
🔑 Ключевое: elision — это чисто синтаксическое удобство компилятора, а не ослабление проверок. Явная и elided форма компилируются в абсолютно одинаковые проверки времени жизни — elision лишь убирает шум там, где связь и так однозначна по трём правилам.

Структуры со ссылками: почему нужен lifetime-параметр

Если поле структуры — ссылка, а не владеющий тип, компилятор обязан гарантировать, что ни один экземпляр структуры не переживёт данные, на которые указывает его поле. Для этого структура сама параметризуется lifetime — это значит, что каждый конкретный экземпляр структуры "привязан" к конкретной области кода, где живут заимствованные данные.

struct Excerpt<'a> {
    part: &'a str, // поле-ссылка требует lifetime
}

impl<'a> Excerpt<'a> {
    fn announce_and_return(&self, announcement: &str) -> &str {
        println!("Внимание: {}", announcement);
        self.part // правило 3 elision: lifetime &self присвоен результату
    }
}

fn main() {
    let novel = String::from("Зовите меня Измаил. Несколько лет назад...");
    let first_sentence = novel.split('.').next().unwrap();

    let excerpt = Excerpt { part: first_sentence };
    // excerpt валиден, пока жив novel — компилятор это проверяет

    // drop(novel);  // если раскомментировать — ОШИБКА:
    // "cannot move out of `novel` because it is borrowed"
    // excerpt всё ещё держит &novel, значит novel нельзя ни
    // переместить, ни уничтожить, пока excerpt используется

    println!("{}", excerpt.part);
}

Для новичка ключевая интуиция: Excerpt<'a> — это не «структура, которая живёт время 'a», а «структура, чьё поле-ссылка гарантированно валидно всё то время, что существует конкретный экземпляр». Компилятор при каждом использовании excerpt проверяет: не пережил ли этот конкретный экземпляр структуры область 'a, в которой создан novel.

⚠️ Частая ошибка новичков: попытка создать структуру со ссылкой на временное значение (&String::from(...) без переменной) приводит к error[E0716]: temporary value dropped while borrowed — временное значение уничтожается в конце выражения, а структура пытается сохранить ссылку на него дольше. Решение: сначала связать значение с переменной (let s = ...;), а уже потом брать от неё ссылку.

HRTB — Higher-Ranked Trait Bounds: for<'a>

Иногда нужно выразить не «эта функция работает с конкретным заранее известным lifetime», а «эта функция (или замыкание) должна работать с любым lifetime, который ей передадут вызывающей стороной — заранее неизвестным». Для этого случая существует синтаксис Higher-Ranked Trait Bounds: for<'a>, читается как «для всех 'a».

Классический пример — trait bound на замыкание, которое принимает и возвращает ссылку с одним и тем же lifetime. Простая запись Fn(&'a str) -> &'a str в generic-контексте не скомпилируется, потому что 'a должен быть связан где-то раньше, а его негде взять — вызывающая функция сама не знает заранее, с какой именно строкой её вызовут:

// Не компилируется в общем случае — 'a не откуда взять:
// fn apply<'a, F>(f: F, s: &'a str) -> &'a str
// where
//     F: Fn(&'a str) -> &'a str
// { f(s) }
// Проблема: 'a здесь жёстко зафиксирован для ОДНОГО конкретного
// вызова apply, но реальная сигнатура функции требует, чтобы f
// работала с ЛЮБЫМ 'a, какой ей подсунут в момент вызова.

// Решение — HRTB: "для любого времени жизни 'a"
fn apply<F>(f: F, s: &str) -> &str
where
    F: for<'a> Fn(&'a str) -> &'a str,
{
    f(s)
}

fn identity(s: &str) -> &str { s }

fn main() {
    let result = apply(identity, "hello");
    println!("{}", result);
}

На практике for<'a> чаще всего появляется неявно — компилятор сам подставляет его для trait bounds вида Fn(&T) -> U, поэтому вживую вы редко пишете HRTB руками. Но он вылезает явно в сообщениях об ошибках компилятора и в сигнатурах трейтов вроде for<'de> Deserialize<'de> из serde — там HRTB буквально означает «этот тип можно десериализовать из данных с абсолютно любым временем жизни источника, заранее неизвестным на момент объявления трейта».

// Пример из реального мира — трейт serde::Deserialize:
fn parse_json<T>(data: &str) -> T
where
    T: for<'de> serde::Deserialize<'de>,
{
    // T должен уметь десериализоваться из ЛЮБОГО источника
    // с любым временем жизни 'de, а не только из конкретного data
    serde_json::from_str(data).unwrap()
}

'static: trait bound против reference — два разных смысла

Слово 'static — источник постоянной путаницы у новичков, потому что оно означает два разных, хотя и связанных, понятия в зависимости от контекста.

&'static T — буквальная ссылка на данные, живущие всю программу

// Строковые литералы всегда имеют тип &'static str —
// они зашиты прямо в бинарник и существуют всё время работы программы
let s: &'static str = "hello, world";

// Можно создать &'static через утечку памяти (осознанно, редко):
fn leak_string(s: String) -> &'static str {
    Box::leak(s.into_boxed_str()) // намеренно "теряем" владение навсегда
}

T: 'static — trait bound, означающий "не содержит невладеющих ссылок короче 'static"

// T: 'static НЕ значит "T живёт вечно"!
// Это значит: T либо владеет своими данными целиком (String, Vec<u8>,
// i32...), либо содержит только ссылки с lifetime 'static.
// Значение типа T: 'static можно спокойно хранить сколь угодно долго —
// оно не станет dangling, потому что ни на что временное не ссылается.

fn spawn_task<T>(value: T)
where
    T: Send + 'static, // требование std::thread::spawn
{
    std::thread::spawn(move || {
        // value может пережить текущий стек — поток независим,
        // поэтому value обязано не ссылаться на что-то временное
        drop(value);
    });
}

fn example() {
    let owned = String::from("owned data"); // String: 'static — ok, владеет данными
    spawn_task(owned); // компилируется

    let local = 42;
    // spawn_task(&local); // ОШИБКА: &i32 из этого стека — не 'static,
    // поток может пережить example(), а &local — нет
}
🔑 Ключевое: "T: 'static" читается как "T не имеет ограничений по времени жизни короче конца программы" — это почти всегда автоматически выполняется для owned-типов (String, Vec<T>, собственные struct без ссылочных полей). А "&'static T" — это конкретная, фактическая ссылка, буквально указывающая на данные в статической памяти бинарника (литералы, статики static X: ... = ...;, либо намеренно "утёкшая" через Box::leak память).
АспектT: 'static (trait bound)&'static T (reference)
СмыслТип не содержит невладеющих ссылок короче 'staticКонкретная ссылка на данные, живущие всю программу
Типичный владелецString, Vec<T>, любой owned-типСтроковый литерал, static-переменная, Box::leak
Где встречаетсяGeneric-bounds: T: Send + 'staticТип поля/переменной: &'static str
Частая ошибка новичкаДумать, что значение "живёт вечно" (нет — оно просто не привязано к чужому borrow)Думать, что любую строку легко сделать 'static (нет — требует владения или утечки)
Типичный контекстthread::spawn, Box<dyn Trait + 'static>Константы, конфигурация, глобальные литералы

Rust vs GC vs ручное управление памятью

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

КритерийC / C++ (ручное управление)Go / Java (GC)Rust (borrow checker)
Когда проверяетсяНикогда (ответственность программиста)В рантайме, постоянноНа этапе компиляции, один раз
Рантайм-издержкиНетПаузы GC, доп. память, косвенностьНет (после компиляции — обычные указатели)
Use-after-freeВозможен, UBНевозможен (GC не удалит, пока есть ссылка)Невозможен (не скомпилируется)
Цена для разработчикаНизкая, но высокий риск баговНизкая, GC "прячет" сложностьВысокая на старте: нужно явно думать о временах жизни
Предсказуемость latencyВысокаяНиже (GC-паузы, stop-the-world)Высокая, как в C/C++
✅ Итог: Rust переносит стоимость проверки безопасности памяти с рантайма (GC-паузы, как в Go/Java) и с разработчика в продакшене (UB, как в C/C++) на этап компиляции. Цена — более крутая кривая обучения, выигрыш — нулевые издержки на проверки в рантайме при сохранённой memory safety.

Borrow checker и Non-Lexical Lifetimes (NLL)

Borrow checker — часть компилятора, которая проверяет правила заимствования: либо одна мутабельная ссылка, либо любое число неизменяемых, но не одновременно, и ни одна ссылка не должна пережить данные, на которые указывает. До Rust 2018 время жизни заимствования (borrow) вычислялось лексически — по границам блока кода { ... }, в котором ссылка была объявлена, даже если фактически она больше не использовалась.

// До NLL (Rust 2015) этот код НЕ компилировался:
fn old_style() {
    let mut v = vec![1, 2, 3];

    let first = &v[0]; // неизменяемое заимствование начинается
    println!("{}", first); // последнее фактическое использование first

    // В "лексической" модели заимствование first формально
    // "живо" до конца блока { }, поэтому строка ниже раньше
    // была ошибкой "cannot borrow `v` as mutable because it
    // is also borrowed as immutable" — хотя first уже не нужен
    v.push(4); // с NLL (Rust 2018+): компилируется без проблем
}

С 2018 edition компилятор использует Non-Lexical Lifetimes (NLL) — заимствование теперь заканчивается не по границе блока, а по факту последнего реального использования ссылки в потоке управления (control-flow graph). Это заметно уменьшило число ложных отказов компиляции для интуитивно корректного кода, не ослабляя гарантий безопасности.

🔑 Ключевое: NLL не меняет ПРАВИЛА заимствования — он лишь точнее вычисляет, ГДЕ заимствование фактически заканчивается. Если сомневаетесь, почему компилятор ругается на, казалось бы, уже неиспользуемую ссылку — проверьте: возможно, ссылка возвращается из функции, попадает в структуру или замыкание, и её реальная область жизни шире, чем кажется на первый взгляд.

Variance: как lifetime влияет на совместимость ссылок

Ещё один нюанс продвинутого уровня — variance (вариантность) типов по lifetime-параметру. Rust позволяет использовать ссылку с более длинным lifetime там, где ожидается более короткий (но не наоборот) — это называется ковариантность (covariance) и работает "из коробки" для обычных ссылок &'a T.

// 'long живёт дольше 'short — это называется 'long: 'short
// (читается "'long переживает 'short")
fn covariance_demo<'short>(s: &'short str) {
    println!("{}", s);
}

fn main() {
    let s: &'static str = "long-lived";
    // &'static str автоматически "сжимается" до любого более
    // короткого lifetime, который требует функция — это и есть
    // ковариантность: &'long T является подтипом &'short T,
    // если 'long: 'short
    covariance_demo(s); // ok, 'static переживает любой 'short
}

Для ссылок &'a T и большинства owned generic-типов вариантность ковариантна и "просто работает", не требуя от вас явных решений. Но для &'a mut T компилятор делает T инвариантным по T (хотя сам 'a остаётся ковариантным) — это защищает от опасных подмен через мутабельный доступ, когда изменение через "укороченную" ссылку могло бы повредить данные с более долгим фактическим lifetime. На практике senior-разработчику важно знать сам термин и то, что он объясняет некоторые неочевидные сообщения об ошибках компилятора при работе с &mut внутри generic-кода — глубокое погружение в variance нужно редко, за исключением написания unsafe-кода и сложных библиотек.

  • Covariance (&'a T) — более длинный lifetime можно использовать вместо более короткого
  • Invariance (&'a mut T по T) — T должен совпадать точно, без подмен, чтобы не допустить некорректной записи
  • Contravariance — встречается редко, в основном в теории и для аргументов функций-указателей

Практические рекомендации

  • Начинайте без аннотаций — доверяйте elision rules, добавляйте 'a только когда компилятор явно попросит
  • Владение проще заимствования — если сигнатура с lifetime получается запутанной, часто проще вернуть owned-тип (String, Vec<T>, Box<T>) и заплатить одним clone/allocation, чем бороться с borrow checker
  • Структуры со ссылками — осознанный выбор — они полезны для zero-copy парсинга (например, &str-срезы внутри распарсенного JSON), но усложняют весь граф владения вокруг них
  • Читайте сообщения об ошибках borrow checker целиком — начиная с Rust 2018 компилятор обычно указывает конкретную причину и часто предлагает готовое исправление (help: ...)
  • 'static — не редкость, но и не дефолт — используйте его осознанно: для конфигурации, констант, каналов между потоками; не пытайтесь "исправить" ошибку borrow checker бездумной заменой lifetime на 'static
// Итоговый пример, собирающий несколько концепций вместе:
#[derive(Debug)]
struct Tokenizer<'a> {
    source: &'a str,
    pos: usize,
}

impl<'a> Tokenizer<'a> {
    fn new(source: &'a str) -> Self {
        Tokenizer { source, pos: 0 }
    }

    // Elision (правило 3): &self присваивает lifetime результату
    fn next_token(&mut self) -> Option<&'a str> {
        let rest = &self.source[self.pos..];
        let trimmed = rest.trim_start();
        if trimmed.is_empty() { return None; }

        let end = trimmed.find(' ').unwrap_or(trimmed.len());
        self.pos += (self.source.len() - rest.len()) + (trimmed.len() - trimmed[end..].len());
        Some(&trimmed[..end]) // ссылка привязана к 'a — исходному source
    }
}

fn main() {
    let text = String::from("fn main lifetimes");
    let mut tok = Tokenizer::new(&text);
    while let Some(word) = tok.next_token() {
        println!("{}", word); // zero-copy: word — срез внутри text, без аллокаций
    }
}