Rust Времена жизни: зачем нужны, как читать аннотации, типичные ошибки
0%

Времена жизни: зачем нужны, как читать аннотации, типичные ошибки

Времена жизни: зачем нужны, как читать аннотации, типичные ошибки

В предыдущей статье мы разобрали владение: у каждого значения ровно один владелец, и когда владелец умирает — память освобождается детерминированно, без сборщика мусора. Оттуда же взялись ссылки: способ дать доступ к данным, не отдавая владения.

И тут возникает вопрос, который в 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 вводят в заблуждение.

Лексическая область против региона NLL

Проверьте себя: поменяйте местами строки 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, и его надо научиться читать автоматически:

  1. где заимствование началось;
  2. где произошёл конфликт;
  3. где заимствование используется позже — именно эта строка не даёт региону закончиться раньше.

Если хотите починить ошибку — почти всегда надо работать с пунктом 3, а не с пунктом 2. Уберите последнее использование или переставьте его — регион сожмётся.

Состояния значения глазами borrow checker

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

Правило «либо один писатель, либо сколько угодно читателей» — не бюрократия, а формулировка отсутствия алиасинга при мутации. Из него бесплатно следуют три вещи: невозможность гонок данных (конкурентность), невозможность итератор-инвалидации, и агрессивные оптимизации компилятора, потому что &mut T даёт LLVM гарантию noalias.

Как работает borrow checker

Полезно знать конвейер, чтобы понимать, откуда берутся сообщения об ошибках.

Ключевой момент, который объясняет 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). Их ровно три, они синтаксические и применяются механически:

  1. Каждая опущенная ссылка в параметрах получает свой собственный параметр времени жизни.
  2. Если входной параметр времени жизни ровно один, он присваивается всем опущенным временам жизни в результате.
  3. Если среди параметров есть &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 в типах — только там, где это оправдано измерениями.

Источники

Что дальше

Типы и трейты: обобщения, диспетчеризация, типажи стандартной библиотеки — перейдём от того, как долго живут значения, к тому, что они умеют: обобщения и мономорфизация, статическая и динамическая диспетчеризация, ключевые типажи стандартной библиотеки и то, как времена жизни встраиваются в систему типов целиком.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов