В 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-параметр — это не время в секундах, а имя для области кода, на протяжении которой ссылка остаётся валидной. Объявляется как 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 компилятор выбирает как пересечение (наименьшую) из областей жизни фактических аргументов в конкретном месте вызова.
Ранний Rust требовал lifetime-аннотации почти везде, что было очень многословно. Начиная с Rust 1.0 компилятор умеет применять lifetime elision rules — три детерминированных правила, которые в частых случаях позволяют опустить явные аннотации. Если после применения всех трёх правил остаётся неоднозначность — компилятор требует аннотацию явно.
// До elision (что компилятор "думает" неявно):
fn foo<'a, 'b>(x: &'a str, y: &'b str) { ... }
// После elision (что вы реально пишете):
fn foo(x: &str, y: &str) { ... }
// До elision:
fn first_word<'a>(s: &'a str) -> &'a str { ... }
// После elision — ровно один входной параметр-ссылка,
// поэтому его lifetime автоматически "перетекает" в возврат:
fn first_word(s: &str) -> &str { ... }
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
}
Если поле структуры — ссылка, а не владеющий тип, компилятор обязан гарантировать, что ни один экземпляр структуры не переживёт данные, на которые указывает его поле. Для этого структура сама параметризуется 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 = ...;), а уже потом брать от неё ссылку.
Иногда нужно выразить не «эта функция работает с конкретным заранее известным 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 — источник постоянной путаницы у новичков, потому что оно означает два разных, хотя и связанных, понятия в зависимости от контекста.
// Строковые литералы всегда имеют тип &'static str —
// они зашиты прямо в бинарник и существуют всё время работы программы
let s: &'static str = "hello, world";
// Можно создать &'static через утечку памяти (осознанно, редко):
fn leak_string(s: String) -> &'static str {
Box::leak(s.into_boxed_str()) // намеренно "теряем" владение навсегда
}
// 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 — нет
}
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> | Константы, конфигурация, глобальные литералы |
Полезно сопоставить, как разные экосистемы решают проблему "не читать память после её освобождения":
| Критерий | C / C++ (ручное управление) | Go / Java (GC) | Rust (borrow checker) |
|---|---|---|---|
| Когда проверяется | Никогда (ответственность программиста) | В рантайме, постоянно | На этапе компиляции, один раз |
| Рантайм-издержки | Нет | Паузы GC, доп. память, косвенность | Нет (после компиляции — обычные указатели) |
| Use-after-free | Возможен, UB | Невозможен (GC не удалит, пока есть ссылка) | Невозможен (не скомпилируется) |
| Цена для разработчика | Низкая, но высокий риск багов | Низкая, GC "прячет" сложность | Высокая на старте: нужно явно думать о временах жизни |
| Предсказуемость latency | Высокая | Ниже (GC-паузы, stop-the-world) | Высокая, как в C/C++ |
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). Это заметно уменьшило число ложных отказов компиляции для интуитивно корректного кода, не ослабляя гарантий безопасности.
Ещё один нюанс продвинутого уровня — 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-кода и сложных библиотек.
&'a T) — более длинный lifetime можно использовать вместо более короткого&'a mut T по T) — T должен совпадать точно, без подмен, чтобы не допустить некорректной записи'a только когда компилятор явно попроситString, Vec<T>, Box<T>) и заплатить одним clone/allocation, чем бороться с borrow checker&str-срезы внутри распарсенного JSON), но усложняют весь граф владения вокруг нихhelp: ...)'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, без аллокаций
}
}