Что такое 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 = || {