Времена жизни: зачем нужны, как читать аннотации, типичные ошибки
В предыдущей статье мы разобрали владение: у каждого значения ровно один владелец, и когда владелец умирает — память освобождается детерминированно, без сборщика мусора. Оттуда же взялись ссылки: способ дать доступ к данным, не отдавая владения.
И тут возникает вопрос, который в C убил больше программ, чем все остальные ошибки вместе взятые: а что, если ссылка переживёт то, на что она ссылается?
// C: компилируется, запускается, иногда даже «работает»
char *broken(void) {
char buf[16];
snprintf(buf, sizeof buf, "hello");
return buf; // buf умер вместе с кадром стека
}
Указатель возвращается, кадр стека затирается следующим же вызовом, и вы читаете чужие байты. Это use-after-free — про его последствия подробно в статье «Неопределённое поведение» трека systems-programming. Microsoft и Google независимо посчитали: около 70% критических уязвимостей в их кодовых базах — это ошибки работы с памятью, и висячие указатели в лидерах.
Времена жизни (lifetimes) — механизм, которым Rust доказывает во время компиляции, что такого не случится. Это не про сборку мусора, не про подсчёт ссылок и вообще не про время выполнения: после проверки все аннотации стираются, в машинном коде их нет. Плата берётся не тактами процессора, а вашим временем на объяснение компилятору того, что вы и так знали.
Что такое время жизни на самом деле
Первая и главная ошибка новичка — читать 'a как «срок жизни». Из-за этого возникает магическое мышление: «добавлю аннотацию — значение проживёт дольше». Не проживёт. Аннотация ничего не продлевает и вообще ни на что не влияет во время выполнения.
Правильная модель — две разные сущности, которые в русском переводе часто сливаются в одно слово:
| Понятие | Что это | Кто определяет |
|---|---|---|
| Время жизни значения | момент от инициализации до вызова Drop |
ваш код: область видимости, move, drop() |
| Время жизни (регион) ссылки | множество точек программы, где ссылка обязана быть валидной | компилятор, решая систему ограничений |
Дальше в тексте «время жизни» без уточнения = регион. Регион — это не отрезок текста и не число секунд. Это подмножество точек в графе потока управления промежуточного представления MIR. Отсюда официальное название механизма: NLL, non-lexical lifetimes — «нелексические времена жизни», то есть не привязанные к фигурным скобкам.
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0]; // регион `first` начинается здесь
println!("{first}"); // ...и заканчивается здесь: последнее использование
v.push(4); // мутация разрешена: заимствование уже закончилось
println!("{v:?}"); // [1, 2, 3, 4]
}
До Rust 2018 этот код не компилировался: заимствование считалось живущим до конца блока. Сегодня — компилируется, и это ровно тот случай, когда старые статьи и StackOverflow вводят в заблуждение.
Проверьте себя: поменяйте местами строки println!("{first}") и v.push(4) — и программа перестанет собираться, хотя ни одна строка не изменилась по содержанию. Регион first растянется до последнего использования и накроет мутацию.
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
--> src/main.rs:5:5
|
4 | let first = &v[0];
| - immutable borrow occurs here
5 | v.push(4);
| ^^^^^^^^^ mutable borrow occurs here
6 | println!("{first}");
| ------- immutable borrow later used here
Обратите внимание на три подчёркивания — это фирменный почерк диагностики заимствований в rustc, и его надо научиться читать автоматически:
- где заимствование началось;
- где произошёл конфликт;
- где заимствование используется позже — именно эта строка не даёт региону закончиться раньше.
Если хотите починить ошибку — почти всегда надо работать с пунктом 3, а не с пунктом 2. Уберите последнее использование или переставьте его — регион сожмётся.
Состояния значения глазами borrow checker
Владение и заимствование удобно держать в голове как автомат состояний. Для каждого места в памяти компилятор отслеживает, в каком состоянии оно находится в каждой точке программы.
Правило «либо один писатель, либо сколько угодно читателей» — не бюрократия, а формулировка отсутствия алиасинга при мутации. Из него бесплатно следуют три вещи: невозможность гонок данных (конкурентность), невозможность итератор-инвалидации, и агрессивные оптимизации компилятора, потому что &mut T даёт LLVM гарантию noalias.
Как работает borrow checker
Полезно знать конвейер, чтобы понимать, откуда берутся сообщения об ошибках.
элизия раскрывается в явные параметры"] B --> C["MIR: тело функции как граф потока управления"] C --> D["Каждой ссылке сопоставляется регион —
множество точек MIR"] D --> E["Собираются ограничения:
регион включает все точки использования"] E --> F["Добавляются ограничения из сигнатур,
границ where и вариантности"] F --> G{"Система разрешима?"} G -- "да" --> H["Регионы стираются:
кодогенерация про них не знает"] G -- "нет" --> I["Ошибка E0106 / E0499 / E0502 /
E0515 / E0597 / E0716"] H --> J["Машинный код: ноль накладных расходов"] I --> K["Диагностика: три точки —
начало, конфликт, позднее использование"]
Ключевой момент, который объясняет 90% вопросов «зачем компилятору аннотация, он же и сам видит»: проверка регионов идёт по одной функции за раз. Когда компилятор проверяет вызывающий код, он не смотрит внутрь тела вызываемой функции — он смотрит только на сигнатуру. Это не лень, а осознанное проектное решение: иначе изменение приватной строчки внутри чужой функции могло бы сломать сборку у вас, а инкрементальная компиляция стала бы невозможной.
Отсюда и вся система аннотаций: сигнатура — это контракт о регионах, а элизия (см. ниже) — просто способ не писать его руками там, где он однозначно выводится.
Первая настоящая ошибка: E0106
// Не компилируется
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}
error[E0106]: missing lifetime specifier
--> src/main.rs:1:33
|
1 | fn longest(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the
signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
|
1 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++
Строчка help здесь — почти учебник. Компилятор говорит: я вижу, что вы возвращаете ссылку, но не знаю, откуда она. И это принципиально: вызывающий код должен понимать, как долго можно держать результат, а внутрь тела компилятор при проверке вызова не заглядывает.
Чиним:
/// Возвращает более длинную из двух строк.
/// 'a — общий регион: результат не переживёт ни x, ни y.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
Как это читать вслух: «для любого региона 'a: если дать мне две строковые ссылки, валидные на всём 'a, я верну ссылку, валидную на всём 'a».
Три следствия, которые надо усвоить намертво:
'a— параметр, как обобщённый типT. Функция его не выбирает; выбирает вызывающий (точнее, выводит компилятор в месте вызова).- Компилятор берёт наименьший подходящий регион. Если
xживёт долго, аyкоротко,'aсхлопнется до регионаy, и результат нельзя будет использовать после смертиy. - Аннотация — ограничение, а не продление. Записи
&'a strнедостаточно, чтобы что-то прожило дольше; это обещание, которое проверят.
Всё, что их связывает, — сигнатура.
Правила элизии: почему аннотаций почти нигде нет
Если бы 'a приходилось писать всегда, на Rust никто бы не писал. Спасают правила элизии времён жизни (Reference: Lifetime elision). Их ровно три, они синтаксические и применяются механически:
- Каждая опущенная ссылка в параметрах получает свой собственный параметр времени жизни.
- Если входной параметр времени жизни ровно один, он присваивается всем опущенным временам жизни в результате.
- Если среди параметров есть
&selfили&mut self, то время жизниselfприсваивается всем опущенным временам жизни в результате.
Разберём на примерах, что во что раскрывается:
fn first_word(s: &str) -> &str { /* ... */ }
// раскрывается по правилам 1 и 2:
fn first_word<'a>(s: &'a str) -> &'a str
fn longest(x: &str, y: &str) -> &str { /* ... */ }
// правило 1 даёт <'a, 'b>, правило 2 не применимо (входов два),
// правило 3 не применимо (нет self) → E0106, пишем руками
impl Parser<'_> {
fn peek(&self) -> Option<&str> { /* ... */ }
}
// правило 3: результат живёт столько же, сколько заимствование self
fn peek<'s>(&'s self) -> Option<&'s str>
Полезно знать и границы механизма:
- Элизия не работает в определениях структур, перечислений, типажей и псевдонимов типов — там всё пишется явно.
'_— «анонимное время жизни»: не «отсутствие», а «выведи сам». ПишитеParser<'_>вместоParser: линтelided_lifetimes_in_pathsиз группыrust_2018_idiomsэто требует, и он прав — иначе из типа не видно, что он что-то заимствует.- В редакции 2024 изменились правила захвата времён жизни в
impl Trait: теперь RPIT захватывает все времена жизни в области видимости. Чтобы сузить захват, есть точный синтаксис+ use<'a, T>, стабилизированный в Rust 1.82. Подробности — в RFC 3617.
Практический совет: не пишите аннотации превентивно. Пишите код без них, и только когда компилятор попросит — добавляйте ровно то, что он просит. rustc в подавляющем большинстве случаев подсказывает правильную починку в блоке help.
Времена жизни в структурах
Структура может хранить ссылку — но тогда сама структура становится «заимствующей», и это отражается в её типе:
/// Парсер, который не копирует вход: все токены — срезы исходной строки.
#[derive(Debug)]
struct Parser<'a> {
input: &'a str,
pos: usize,
}
impl<'a> Parser<'a> {
fn new(input: &'a str) -> Self {
Parser { input, pos: 0 }
}
/// Возвращаем срез с временем жизни 'a — исходной строки,
/// а НЕ временем жизни &self. Это принципиально: результат
/// переживёт заимствование самого парсера.
fn next_word(&mut self) -> Option<&'a str> {
let rest = &self.input[self.pos..];
let start = rest.find(|c: char| !c.is_whitespace())?;
let rest = &rest[start..];
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
self.pos += start + end;
Some(&rest[..end])
}
}
fn main() {
let text = String::from(" однажды в студёную пору");
let mut p = Parser::new(&text);
while let Some(w) = p.next_word() {
print!("[{w}]");
}
// Вывод: [однажды][в][студёную][пору]
}
Здесь стоит остановиться. fn next_word(&mut self) -> Option<&'a str> — если бы мы понадеялись на элизию, правило 3 привязало бы результат к &mut self, и вы не смогли бы собрать Vec всех слов: каждый следующий вызов конфликтовал бы с предыдущим результатом. Явная 'a говорит: «срез указывает в input, а не в парсер». Это типовой приём всех zero-copy парсеров.
Parser<'a> читается так: «Parser — это семейство типов, по одному на каждый регион 'a». Отсюда важное практическое следствие — вирусность: любая структура, хранящая Parser<'a>, тоже обязана иметь параметр 'a, и так вверх по всей иерархии. Прежде чем закладывать ссылку в поле, спросите себя, не проще ли владеть данными.
Самоссылающиеся структуры: то, чего в Rust нельзя
// Так не получится ни при каких аннотациях
struct SelfRef {
data: String,
slice: &str, // хотим указывать внутрь data
}
error[E0106]: missing lifetime specifier
--> src/main.rs:3:12
|
3 | slice: &str,
| ^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
1 ~ struct SelfRef<'a> {
2 | data: String,
3 ~ slice: &'a str,
|
Совет компилятора здесь не поможет: добавив 'a, вы получите структуру, которая заимствует нечто внешнее, а не своё же поле. Заполнить slice ссылкой на data не даст borrow checker — вы бы одновременно владели data и держали на неё ссылку внутри того же значения.
Причина фундаментальная и стоит того, чтобы её понять: перемещение в Rust — это побайтовое копирование (memcpy) без вызова кода пользователя. Сдвинули структуру на другой адрес — внутренний указатель стал висячим. Именно поэтому самоссылающиеся типы требуют Pin (о нём — в статье про асинхронный Rust, где самоссылающимися оказываются сгенерированные компилятором Future).
Что делать вместо этого:
| Приём | Когда подходит | Цена |
|---|---|---|
Хранить индексы Range<usize> вместо срезов |
графы, деревья, арены, парсеры | нужен доступ к владельцу при чтении |
Владеть данными: String, Vec<T>, Box<str> |
почти всегда в прикладном коде | аллокация и копирование |
Rc<T> / Arc<T> |
разделяемое владение, дерево виджетов, конфиг | счётчик ссылок, риск циклов |
Арена: bumpalo, typed-arena |
много мелких объектов с общим сроком жизни | всё живёт до сброса арены |
Слотовая карта: slotmap |
графы с удалением узлов | ключ вместо указателя |
ouroboros, self_cell, yoke |
реально нужен самоссылающийся тип | макросы, unsafe внутри, сложные ошибки |
'static: одна запись, два разных смысла
Это, пожалуй, самый частый источник путаницы во всём языке. 'static встречается в двух синтаксически похожих, но семантически разных позициях.
Смысл первый — тип ссылки &'static T: данные живут до конца программы. Так устроены строковые литералы (&'static str лежит в секции .rodata исполняемого файла — см. раскладку памяти процесса), элементы static и всё, что вы намеренно «утекли» через Box::leak.
Смысл второй — граница на типе T: 'static: тип не содержит заимствований с более коротким регионом. Ключевое: это свойство типа, а не значения. String удовлетворяет T: 'static, хотя конкретная строка умрёт через две строки кода.
fn require_static<T: 'static>(_value: T) {}
fn main() {
let s = String::from("привет");
require_static(s); // OK! String: 'static — внутри нет чужих ссылок
// сама переменная при этом умрёт прямо здесь
let text = String::from("привет");
let r: &str = &text;
// require_static(r); // ОШИБКА: &'short str не удовлетворяет 'static
}
Практическое место, где это выстреливает — порождение потоков и задач:
use std::thread;
fn main() {
let data = vec![1, 2, 3];
// Не компилируется: замыкание заимствует data,
// а поток может пережить main
// let h = thread::spawn(|| println!("{data:?}"));
let h = thread::spawn(move || println!("{data:?}")); // move отдаёт владение
h.join().unwrap();
}
error[E0373]: closure may outlive the current function, but it borrows `data`,
which is owned by the current function
--> src/main.rs:7:28
|
7 | let h = thread::spawn(|| println!("{data:?}"));
| ^^ ---- `data` is borrowed here
| |
| may outlive borrowed value `data`
|
note: function requires argument type to outlive `'static`
help: to force the closure to take ownership of `data` (and any other referenced
variables), use the `move` keyword
|
7 | let h = thread::spawn(move || println!("{data:?}"));
| ++++
Тот же 'static вы увидите в tokio::spawn, в Box<dyn Error + Send + Sync + 'static> и в anyhow::Error. Всякий раз читайте это как «тип не должен тащить внутри чужих коротких ссылок», а не как «объект бессмертен».
Если поток реально должен заимствовать локальные данные — начиная с Rust 1.63 в стандартной библиотеке есть thread::scope, который статически гарантирует, что все потоки завершатся до выхода из области видимости. Тогда 'static не требуется.
Границы: 'a: 'b, T: 'a и времена жизни объектов-типажей
Иногда одного общего 'a мало, и нужно выразить отношение между регионами.
/// 'long: 'short читается «'long живёт не меньше, чем 'short»
/// (аналогия: подтип — более длинный регион годится там, где ждут короткий)
fn pick<'long: 'short, 'short>(a: &'long str, b: &'short str, first: bool) -> &'short str {
if first { a } else { b }
}
/// T: 'a — «тип T не содержит ссылок короче, чем 'a».
/// Без этой границы структуру нельзя определить корректно.
struct Wrapper<'a, T: 'a> {
value: &'a T,
}
Если границу забыть там, где она нужна, компилятор выдаёт узнаваемое сообщение — без кода ошибки, просто текстом:
error: lifetime may not live long enough
--> src/main.rs:2:5
|
1 | fn wrong<'a, 'b>(x: &'a str, y: &'b str) -> &'a str {
| -- -- lifetime `'b` defined here
| |
| lifetime `'a` defined here
2 | y
| ^ function was supposed to return data with lifetime `'a`
| but it is returning data with lifetime `'b`
|
= help: consider adding the following bound: `'b: 'a`
Строка help: consider adding the following bound почти всегда права. Но прежде чем механически её применить — подумайте, действительно ли вы хотите такого контракта: часто честнее вернуть String и не мучиться.
Отдельная ловушка — времена жизни по умолчанию у объектов-типажей (Reference: Trait object lifetime bounds):
Box<dyn Fn()> // на самом деле Box<dyn Fn() + 'static>
&'a dyn Fn() // на самом деле &'a (dyn Fn() + 'a)
Rc<dyn Error> // Rc<dyn Error + 'static>
Отсюда классическое недоумение: «почему Box<dyn Fn()> не принимает моё замыкание, оно же просто печатает строку?» — потому что замыкание захватило ссылку, а Box<dyn Fn()> молча требует 'static. Решения: move-замыкание либо явная запись Box<dyn Fn() + 'a>.
Вариантность: почему &mut T ведёт себя иначе
Это уже продвинутая тема, но без неё некоторые ошибки выглядят необъяснимо. Вариантность отвечает на вопрос: если 'long содержит 'short, то как соотносятся F<'long> и F<'short>?
| Конструкция | По времени жизни | По типу T |
|---|---|---|
&'a T |
ковариантна | ковариантна |
&'a mut T |
ковариантна | инвариантна |
Box<T>, Vec<T> |
— | ковариантна |
Cell<T>, RefCell<T>, UnsafeCell<T> |
— | инвариантна |
fn(T) -> U |
— | контравариантна по T, ковариантна по U |
Ковариантность &'a T — то, чем вы пользуетесь ежедневно, не замечая: &'static str спокойно передаётся туда, где ждут &'a str. Более долгоживущая ссылка годится вместо короткой.
А вот инвариантность &mut T по T спасает от вполне конкретной дыры (пример из Rustonomicon):
fn assign<T>(input: &mut T, val: T) {
*input = val;
}
fn main() {
let mut hello: &'static str = "привет";
{
let world = String::from("мир");
assign(&mut hello, &world); // если бы &mut был ковариантен — прошло бы
}
println!("{hello}"); // ...и мы бы читали освобождённую память
}
error[E0597]: `world` does not live long enough
--> src/main.rs:9:28
|
8 | let world = String::from("мир");
| ----- binding `world` declared here
9 | assign(&mut hello, &world);
| ^^^^^^ borrowed value does not live long enough
10 | }
| - `world` dropped here while still borrowed
11 | println!("{hello}");
| ------- borrow later used here
Инвариантность — не каприз: она напрямую предотвращает подмену долгоживущей ссылки короткоживущей через мутацию. Тот же механизм заставляет PhantomData<T> выбирать в unsafe-структурах (тема статьи «unsafe и FFI»): PhantomData<fn() -> T> даёт ковариантность, PhantomData<fn(T)> — контравариантность, PhantomData<Cell<T>> — инвариантность.
HRTB: for<'a> и «implementation is not general enough»
Иногда функция должна работать для любого региона, а не для одного конкретного, выбранного вызывающим. Это higher-ranked trait bounds:
/// for<'a> читается «для всякого 'a»: f обязана работать
/// с любой строкой, независимо от того, сколько та живёт.
fn apply<F>(f: F, s: &str) -> &str
where
F: for<'x> Fn(&'x str) -> &'x str,
{
f(s)
}
fn main() {
println!("{}", apply(|s| s.trim(), " край ")); // "край"
}
Хорошая новость: запись F: Fn(&str) -> &str автоматически раскрывается в F: for<'a> Fn(&'a str) -> &'a str, так что руками for<'a> пишут редко. Плохая новость: когда вывод типов замыкания сломается, сообщение будет пугающим:
error: implementation of `FnOnce` is not general enough
--> src/main.rs:8:5
|
8 | apply(f, "abc");
| ^^^^^^^^^^^^^^^ implementation of `FnOnce` is not general enough
|
= note: closure with signature `fn(&'2 str) -> &'2 str` must implement
`FnOnce<(&'1 str,)>`, for any lifetime `'1`...
= note: ...but it actually implements `FnOnce<(&'2 str,)>`,
for some specific lifetime `'2`
Перевод на человеческий: «от вас ждут замыкание, работающее с любым регионом, а вы дали замыкание, привязавшееся к одному конкретному». Типовая причина — замыкание сначала положили в let, и компилятор зафиксировал регион по первому применению. Лечится аннотацией аргумента:
let f = |s: &str| -> &str { s.trim() }; // явные типы возвращают общность
Галерея ошибок: что на самом деле говорит компилятор
Разберём три самых частых по существу.
E0515 — возврат ссылки на локальное значение. Тот самый случай из C, с которого мы начали:
fn broken() -> &String {
let result = String::from("привет");
&result
}
error[E0515]: cannot return reference to local variable `result`
--> src/main.rs:3:5
|
3 | &result
| ^^^^^^^ returns a reference to data owned by the current function
Никакая аннотация это не чинит, и это правильно. Вариантов ровно два: вернуть владение (-> String) или принимать ссылку на буфер снаружи (fn write_into(buf: &mut String)).
E0716 — временное значение. Ловушка, на которую попадаются даже опытные:
struct Config { name: String }
fn load() -> Config { Config { name: "prod".into() } }
fn main() {
let name = &load().name; // load() создаёт временное значение
println!("{name}");
}
error[E0716]: temporary value dropped while borrowed
--> src/main.rs:5:17
|
5 | let name = &load().name;
| ^^^^^^ creates a temporary value which is freed while still in use
6 | println!("{name}");
| ------ borrow later used here
|
help: consider using a `let` binding to create a longer lived value
|
5 ~ let binding = load();
6 ~ let name = &binding.name;
|
Временное значение живёт до конца оператора, а не до конца блока. Есть исключение — «продление времени жизни временных» для let x = &temp; в простейших формах, но оно хрупкое и на цепочки методов не распространяется. В редакции 2024, кстати, поменялись правила для временных в хвостовых выражениях и в if let — чуть менее неожиданно, чем раньше (Edition Guide).
E0499 — два эксклюзивных заимствования. Классика при работе с массивами:
let mut v = vec![1, 2, 3, 4];
let a = &mut v[0];
let b = &mut v[1]; // E0499: компилятор не знает, что индексы разные
*a += *b;
Компилятор рассуждает о v как о целом: IndexMut — обычный метод, берущий &mut self. Идиоматичное решение — разрезать срез, а не индексировать:
let mut v = vec![1, 2, 3, 4];
let (left, right) = v.split_at_mut(1); // два непересекающихся среза
left[0] += right[0];
println!("{v:?}"); // [3, 2, 3, 4]
Тот же приём в структурах: разбивайте методы так, чтобы они брали &mut на отдельные поля, а не на весь self. Заимствование разных полей одной структуры компилятор разрешает — но только когда видит их напрямую, а не через вызов метода, который берёт весь self.
Известное ограничение: то, что отвергается зря
Честность требует сказать: borrow checker консервативен. Есть корректные программы, которые он не пропускает. Самый известный пример — «NLL problem case #3», условный возврат заимствования:
use std::collections::HashMap;
fn get_or_insert(map: &mut HashMap<u32, String>) -> &String {
match map.get(&22) {
Some(v) => v, // здесь живёт shared-заимствование map
None => {
map.insert(22, "default".into()); // ...и оно мешает mut-заимствованию
map.get(&22).unwrap()
}
}
}
error[E0502]: cannot borrow `*map` as mutable because it is also borrowed as immutable
Программа корректна: в ветке None первое заимствование уже точно не используется. Но текущая реализация регионов этого не выражает. Над решением работают в рамках проекта Polonius — переформулировке проверки заимствований в терминах логического вывода; в стабильном компиляторе на момент написания эта форма всё ещё отвергается. Обходные пути: if map.contains_key(&22) { ... } с повторным поиском, entry API (map.entry(22).or_insert_with(...) — лучший вариант), либо приём с ранним возвратом.
Знать про это ограничение полезно психологически: если вы уверены, что код корректен, вы не обязательно ошибаетесь. Иногда прав вы, а компилятор просто недостаточно умён.
Практика: zero-copy парсинг
Ради чего вообще терпеть всю эту сложность? Ради того, чтобы разбирать данные без единой аллокации, и при этом безопасно.
/// Разобранная строка лога. Ни одного String — только срезы входа.
#[derive(Debug, PartialEq)]
struct LogLine<'a> {
ts: &'a str,
level: &'a str,
message: &'a str,
}
/// Формат: "2026-07-16T10:00:00Z WARN сообщение с пробелами"
/// Возврат Option<LogLine<'_>>: элизия привяжет регион к line.
fn parse(line: &str) -> Option<LogLine<'_>> {
let (ts, rest) = line.split_once(' ')?;
let (level, message) = rest.split_once(' ')?;
Some(LogLine { ts, level, message: message.trim() })
}
fn main() {
let buffer = String::from("2026-07-16T10:00:00Z WARN диск заполнен на 91%");
let entry = parse(&buffer).expect("строка разобрана");
println!("{entry:?}");
// LogLine { ts: "2026-07-16T10:00:00Z", level: "WARN", message: "диск заполнен на 91%" }
assert_eq!(entry.level, "WARN");
// entry заимствует buffer: drop(buffer) здесь не скомпилируется — и это хорошо
}
Три поля LogLine — это три пары (указатель, длина), 48 байт на стеке и ноль обращений к аллокатору. В Go или Java такой парсер породил бы три объекта в куче на каждую строку; при миллионе строк в секунду разница видна невооружённым глазом (почему именно — в статьях «Память и аллокации» и «Кэш и локальность»).
Ровно на этом принципе построены serde с атрибутом #[serde(borrow)], парсеры nom и winnow, regex с Captures, и вообще весь высокопроизводительный ввод-вывод в Rust.
Когда заимствование не всегда возможно, есть Cow — «clone on write», один из самых недооценённых типов стандартной библиотеки:
use std::borrow::Cow;
/// Экранируем кавычки. Если экранировать нечего — не аллоцируем вовсе.
fn escape(input: &str) -> Cow<'_, str> {
if input.contains('"') {
Cow::Owned(input.replace('"', "\\\""))
} else {
Cow::Borrowed(input)
}
}
fn main() {
println!("{}", escape("обычная строка")); // без аллокации
println!("{}", escape(r#"строка с "кавычками""#)); // строка с \"кавычками\"
}
Заимствовать или владеть: как принимать решение
Главный совет всей статьи: времена жизни — оптимизация, а не обязанность. Начинайте с владения, переходите на заимствование там, где измерения показали смысл.
Практическая эвристика по слоям приложения:
- Границы API и слой данных — владеть.
String,Vec<T>, собственные типы. Никаких'aв публичных структурах, если только вы не пишете библиотеку парсинга. - Аргументы функций — заимствовать: принимайте
&strвместоString,&[T]вместоVec<T>. Это бесплатно и не заражает типы. - Возвращаемые значения — по умолчанию владеть.
-> Stringне требует объяснений,-> &'a strтребует. - Внутренние горячие циклы — заимствовать агрессивно, вплоть до
'aв структурах.
И отдельно: clone() — не поражение. Клонирование String на 40 байт в обработчике HTTP-запроса, который и так делает поход в базу, не измеримо ничем. Правило «сначала заработало, потом профилировали» (методика — в треке performance) в Rust работает так же, как везде.
Что это стоит: честно
Времена жизни — самая дорогая часть кривой обучения Rust, и об этом надо говорить прямо.
Что вы получаете:
- Отсутствие use-after-free, двойного освобождения, висячих указателей и итератор-инвалидации — доказанное, а не «мы старались». Ср. с ручным управлением памятью в треке systems-programming, где эти ошибки ловятся только санитайзерами и то не всегда.
- Отсутствие гонок данных — прямое следствие правила «один писатель или много читателей», распространённого на потоки.
- Zero-copy как нормальная практика, а не героизм.
- Предсказуемая задержка: нет пауз сборщика мусора, освобождение детерминированно.
Что вы платите:
- Кривая обучения. Первые две-три недели уходят на «борьбу с borrow checker». Это нормальная фаза, через неё проходят все, и она заканчивается — обычно после того, как человек перестаёт добавлять аннотации наугад и начинает думать регионами.
- Вирусность параметров. Один
'aв структуре просачивается вверх по всей иерархии типов. Иногда рефакторинг состоит в том, чтобы этот'aубрать и начать владеть. - Скорость компиляции. Проверка заимствований — не главный вклад (основное съедают мономорфизация обобщений, LLVM и линковка), но большой проект на Rust собирается заметно дольше сопоставимого на Go. Меры — воркспейс,
cargo checkв цикле разработки, быстрый линкерlld/mold,sccache. - Пределы выразимости. Самоссылающиеся структуры, произвольные графы с циклами, некоторые состояния UI — всё это в Rust выражается заметно тяжелее, чем в языке со сборщиком мусора.
Куда Rust тащить не стоит: одноразовые скрипты и склейка утилит; прототипы с ежедневно меняющимися требованиями; типовой CRUD-бэкенд в команде без опыта Rust, где узкое место — база и сеть, а не CPU; предметные области, где готовая экосистема есть только на Python (научные вычисления, значительная часть ML). Обратное тоже верно: сетевые прокси, парсеры, движки баз данных, встраиваемые системы, WebAssembly, обработка недоверенного ввода — здесь Rust выигрывает у альтернатив по совокупности «безопасность плюс скорость».
Мини-итог
- Время жизни — это регион, множество точек в графе потока управления, а не срок в секундах и не «до конца блока».
- Регион ссылки тянется от создания до последнего использования (NLL). Хотите починить ошибку — двигайте последнее использование, а не место конфликта.
- Аннотация
'aничего не продлевает. Она связывает регионы через границу функции, потому что при проверке вызова компилятор смотрит только на сигнатуру. - Три правила элизии убирают аннотации из большинства кода. Не пишите их превентивно — ждите, пока компилятор попросит, и читайте блок
help. &'static T(данные живут всю программу) иT: 'static(тип не содержит коротких ссылок) — разные вещи;Stringудовлетворяет второму и не имеет отношения к первому.- Структура с
'aзаимствует и становится вирусной. Самоссылающиеся структуры невозможны в принципе:move— этоmemcpy. - Инвариантность
&mut TпоT— не каприз, а защита от подмены долгоживущей ссылки короткоживущей. - Компилятор консервативен: часть корректных программ он отвергает (условный возврат заимствования). Обходите через
entry, индексы или ранний возврат. - Стратегия по умолчанию: владеть, заимствовать в аргументах,
clone()без стыда, а'aв типах — только там, где это оправдано измерениями.
Источники
- The Rust Programming Language, глава 10.3 «Validating References with Lifetimes» — канонический вход в тему.
- The Rust Reference: Lifetime elision — точная формулировка правил элизии и времён жизни объектов-типажей.
- The Rustonomicon: Ownership and Lifetimes, Subtyping and Variance, Higher-Rank Trait Bounds — вариантность и HRTB с примерами дыр, которые они закрывают.
- RFC 2094: Non-Lexical Lifetimes — исходное обоснование модели регионов, включая знаменитые «problem cases».
- Rust Compiler Development Guide: Borrow checker — как проверка устроена внутри, поверх MIR.
- Common Rust Lifetime Misconceptions — 22 разобранных заблуждения, лучший текст по теме после официальных.
- Jon Gjengset, «Rust for Rustaceans» — главы 1–2 про типы, вариантность и времена жизни на продакшн-уровне.
- Jim Blandy, Jason Orendorff, Leonora Tindall, «Programming Rust», 2nd ed. — главы 5 и 11, лучшее печатное объяснение ссылок.
- Rust Error Codes Index — расшифровка любого
E0xxx; локально то же самое даётrustc --explain E0597. - Polonius и доклад Niko Matsakis о нём — куда движется проверка заимствований.
- Rust Edition Guide: Rust 2024 — изменения в захвате времён жизни в
impl Traitи в области временных значений.
Что дальше
Типы и трейты: обобщения, диспетчеризация, типажи стандартной библиотеки — перейдём от того, как долго живут значения, к тому, что они умеют: обобщения и мономорфизация, статическая и динамическая диспетчеризация, ключевые типажи стандартной библиотеки и то, как времена жизни встраиваются в систему типов целиком.