Non-lexical lifetimes в Rust: что это и как упрощает код

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Что такое non-lexical lifetimes? Знакомство с главным улучшением Rust

Каждый, кто писал на Rust хотя бы пару недель, сталкивался с ситуацией: код абсолютно логичен, безопасен, но компилятор (а точнее, его строгий borrow checker) отказывается его компилировать. Вы смотрите на ошибку и думаете: «Но я же больше не использую эту ссылку! Почему он ругается?».

До версии Rust 1.31 (2018 edition) ответ был прост: «Потому что время жизни переменной (lifetime) определяется областью видимости — лексически». Это lexical lifetimes. Начиная с Rust 2018, компилятор стал умнее. Он получил non-lexical lifetimes (NLL) — нелексические времена жизни.

В этой статье мы разберёмся, что это за зверь, почему его появление стало революцией для Rust-разработчиков, и как он делает код чище и интуитивнее. Мы не будем углубляться в дебри теории типов, а сосредоточимся на практических примерах.

Проблема lexical lifetimes: почему borrow checker был слишком строгим

Давайте вспомним, как работает заимствование (borrowing) в Rust. У нас есть правила:

  • В любой момент времени может быть либо одна изменяемая ссылка (&mut T), либо любое количество неизменяемых (&T).
  • Ссылки всегда должны быть валидными (нельзя ссылаться на удалённые данные).

До NLL компилятор определял время жизни ссылки по её лексической области видимости. То есть от { до }, где переменная объявлена. Если ссылка создана внутри блока, она «живёт» до конца этого блока, даже если вы её больше не используете.

Посмотрите на этот классический пример, который не компилировался в старых версиях Rust:

fn main() {
    let mut data = vec![1, 2, 3];

    let x = &data[0];  // неизменяемая ссылка

    data.push(4);       // попытка изменяемого заимствования

    println!("{}", x);  // использование неизменяемой ссылки
}

С точки зрения человека: мы создали ссылку x, потом изменили вектор, потом использовали x. Но вектор data при вызове push мог перераспределить память, и ссылка x стала бы невалидной. Компилятор прав — это опасно.

Но что, если мы используем ссылку до изменения? Вот настоящая боль:

fn main() {
    let mut name = String::from("Alice");

    let first_char = &name[0..1]; // неизменяемая ссылка
    println!("First char: {}", first_char); // используем её

    name.push_str(" Johnson"); // изменяем строку
    println!("Full name: {}", name);
}

Этот код безопасен. Мы использовали ссылку first_char, вывели её, а потом уже изменили строку. Но до Rust 2018 компилятор выдавал ошибку! Почему? Потому что лексическое время жизни ссылки first_char длилось до конца функции main, то есть до закрывающей фигурной скобки. А изменяемое заимствование (push_str) внутри этого промежутка запрещено.

Разработчикам приходилось использовать костыли: вводить дополнительные блоки {}, чтобы ограничить время жизни ссылки вручную:

fn main() {
    let mut name = String::from("Alice");

    {
        let first_char = &name[0..1];
        println!("First char: {}", first_char);
    } // ссылка first_char умирает здесь

    name.push_str(" Johnson");
    println!("Full name: {}", name);
}

Это работало, но было неудобно, засоряло код лишними скобками и затрудняло чтение. Особенно в сложных функциях с несколькими заимствованиями.

Non-lexical lifetimes (NLL): как это работает

Идея NLL гениальна в своей простоте: время жизни ссылки определяется не областью видимости, а тем, где она реально используется. Компилятор анализирует поток управления (control flow) и определяет «жива» ли ссылка в каждой конкретной точке программы.

Если ссылка больше не используется (последняя точка использования — last use), её время жизни считается законченным, даже если мы всё ещё находимся внутри лексического блока. Это позволяет совмещать, казалось бы, несовместимые заимствования.

Вернёмся к нашему примеру. Благодаря NLL он теперь компилируется без ошибок:

fn main() {
    let mut name = String::from("Alice");

    let first_char = &name[0..1];
    println!("First char: {}", first_char); // последнее использование first_char

    name.push_str(" Johnson"); // теперь можно! first_char больше не жива
    println!("Full name: {}", name);
}

Компилятор видит: после println! ссылка first_char больше нигде не используется. Значит, её время жизни заканчивается в этой точке. А push_str происходит уже после «смерти» ссылки — всё безопасно.

Более сложный пример: ветвления и циклы

NLL работает не только в линейном коде, но и с ветвлениями. Рассмотрим пример:

fn main() {
    let mut x = 10;

    let y = &mut x;  // изменяемая ссылка

    if *y > 5 {
        println!("y is {}", y); // последнее использование y в этом пути
    }

    // Здесь y уже не используется
    let z = &x; // неизменяемая ссылка — ок!
    println!("z is {}", z);
}

В этом коде y — изменяемая ссылка. Она используется только внутри условия if. После этого компилятор понимает, что y больше не нужна, и позволяет создать неизменяемую ссылку z. Без NLL это было бы невозможно: y «жила» бы до конца main.

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

fn main() {
    let mut data = vec![1, 2, 3];

    for i in 0..data.len() {
        let item = &data[i]; // неизменяемая ссылка
        // ... используем item
        // data.push(4); // ОШИБКА! item всё ещё жива внутри цикла
    }

    // А вот здесь — можно
    data.push(4);
}

Здесь item живёт на каждой итерации, но после выхода из цикла все ссылки, созданные внутри, умирают. Поэтому push после цикла разрешён.

NLL и замыкания (closures)

Замыкания — ещё одна область, где NLL сильно упростил жизнь. Рассмотрим пример:

fn main() {
    let mut vec = vec![1, 2, 3];

    let mut closure = || {                

Блоги

Рекомендаций по книгам