Главная » Хабрахабр » Ещё одна статья о временах жизни (lifetimes) в Rust

Ещё одна статья о временах жизни (lifetimes) в Rust

Некоторые на этом ломаются, но тем, кто смог пережить — это уже не кажется чем-то необычным или неправильным. Первые месяцы начинающего растомана как правило сводятся к ударам головой о концепцию времени жизни и владения. Я опишу ключевые моменты, которые, как мне кажется, помогли быстрее и лучше адаптироваться к концепции лайфаймов и владений.

Я попытался избежать большого количества деталей и представить всё в порядке возрастания сложности, в попытке сделать данную статью доступней тем, кто или только начал смотреть раст, или же не очень понял начальные моменты из официального растбука. Разумеется официальный растбук полнее и подробнее, но так же требует больше времени и терпения для полного понимания и впитывания всей информации.

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

Время жизни (lifetime)

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

Прежде всего, жизнь значения определается следующим отрезком:

  • Начало жизни: создание значения. Это привычно для большинства языков программирования, так что никакую необычную нагрузку не несёт.
  • Окончание жизни. Это именно то место, где Rust автоматически вызовет деструктор и забудет о значении. В блоке (scope) без перемещений это произойдёт в конце этого блока. Именно мысленное отслеживание окончания жизни и является, по-моему, ключевыми для успешного взаимодействия с borrowchecker.

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

Ещё один момент: я буду создавать строку, так как она не имеет маркера Copy, а значения, который имеют этот маркер, не перемещаются а копируются, что считается довольно дешёвой операцией, но меняет поведение перемещения (и позволяет легче работать с примитивными типами), но об этом чуть позже.

Примеры можно запустить тут: https://play.rust-lang.org/

fn main() // это конец блока // тут уже нет ни "a" ни "b" нету
}

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

Перемещение

Простыми словами "перемещение" значит — что текущий блок больше не интересует судьба значения, и распоряжение его судьбой передано в другой блок, например в другую функцию, или в замыкание, или просто в другое значение. Добавим такое понятие как перемещение значения.

fn f<T: std::fmt::Display>(x: T) { // я воспользуюсь дженериком, чтобы принимать и строку и цифру в этом примере. println!("{}", x); // <- конец блока, куда переместили "a", тут оно и удаляется.
} fn main() { let a = "a".to_string(); // "a" начинает жить тут let b = 2; f(a); // мы перемещаем "a" в f // если на этой строке мы повторно вызовем f(a) - то появится ошибка, так как значение "a" перемещено и уже не присутствует в данном блоке. Если мы вместо a передадим b, то всё будет работать, а как число имеет маркер Copy и будет скопировано. // "b" удаляется.
}

С замыканиями.

Чтобы замыкание перемещало в свой блок захватываемое значения используется ключевое слово move, если не писать move — происходит "одалживание" значения, о котором я напишу совсем скоро.

fn main() { let a = "a".to_string(); // "a" начинает жить тут let b = 2; let f_1 = move || {println!("{}", a)}; // замыкание захватывает "a" // повторная попытка захватить "a" следующей строкой вызовет ошибку. // let f_2 = move || {println!("{}", a)}; f_1();
}

Перемещать можно как в функцию так и из функции или в другое значение.

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

fn f(x: String) -> String { x + " and x" // в данном случае x перемещается в +, где и завершат свой путь. // потом + возвращает новый String, который возвращается из функции. } fn main() { let a = "a".to_string(); // создаём "a" let b = f(a); // перемещаем "a" в "f", затем f отдаёт свой результат в b. println!("{}", b); // "a" сдесь уже нет.
}

Одалживание

Введём это новое понятие: в отличие от перемещения, это значит что управление значением текущий блок оставляет за собой, просто разрешает другому блоку попользоваться своим значением.

Замечу, что одалживание тоже имеет место где оно завершилось, что не очень важно в данных примерах, но всплывёт в следующем пункте.

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

fn f(x: &String) { // явно указал &, что значие будет одолжено. println!("{}", x); // <- конец блока, но "x" не пренадлежит этому блоку
} fn main() { let a = "a".to_string(); // "a" начинает жить тут f(&a); // мы одолжили "a" в f // одалживание завершено f(&a); // одолжили ещё раз - никаких проблем. println!("{}", a); // печатаем значение // "a" удаляется тут.
}

С замыканиями аналогично:

fn main() { let mut a = "a".to_string(); // "a" начинает жить тут let f_1 = || a.push_str("and x"); // замыкание одалживает "a" let f_2 = || a.push_str("and x"); // ещё раз f_1(); f_2(); println!("{}", a); // "a" удаляется тут.
}

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

Мутабельность

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

Пример, не связанный с предыдущими, когда данная концепция нас убережёт от проблем, запретив мутабельное и немутабельное одалживание одновременно:

fn main() { let mut a = "abc".to_string(); for x in a.chars() { // немутабельное одалживание a.push_str(" and "); // мутабельное одалживание. Ошибка компиляции. a.push(x); }
}

В примере выше, самым простым было бы клонирование "a" -> немутабельное одалживание будет у клона, и не относиться к оригинальному "a". Тут уже надо запасаться различными приёмами, чтобы удовлетворить, в большинстве своём, справедливые претензии раста.

for x in a.clone().chars() { // немутабельное одалживание, но уже клона. a.push_str(" and "); // мутабельное одалживание. У каждого по одному одалживанию - всё в порядке.

Нам надо изменить "a" и у нас это не получается. Но я лучше вернусь к нашим примерам, чтобы сохранить последовательность.

fn main() { let mut a = "a".to_string(); // "a" начинает жить тут let mut f_1 = || a.push_str(" and x"); // замыкание одалживает "a". небольшой ньюанс - замыкание, замыкающее mut тоже mut. // в данном случае одалживание не завершено, так как f_1 используется дальше. let mut f_2 = || a.push_str(" and y"); // а вот тут возникает ошибка: second mutable borrow occurs here f_1(); f_2(); println!("{}", a);
}

Скрытое мутирование

Теоретически замыкание может быть передано в какую-то функцию, которая обрабатывает, например, асинхронно в другом потоке, и тогда действительно у нас были бы проблемы, но в данном случае borrowchecker перестраховывается, хотя это не отменяет того что с ним надо как-то договориться.

Итого: нам нужно два мутирующих-одалживания, но раст позволяет только одно, но хитрые изобретатели раста придумали "скрытое мутирование": RefCell.

Т.е. RefCell — то, что мы заворачиваем в RefCell — раст считает немутируемым, однако, использовав функцию borrow_mut() мы можем временно извлечь мутабельную ссылку по которой может изменить значение, но есть важный ньюанс: ссылку удастся получить только когда RefCell в runtime убедится что нет других активных одалживаний, иначе он кинет panic, или вернёт ошибку, если использовать try_borrow_mut(). тут раст отдаёт все заботы об одалживании на попечение пользователя, а он уже сам должен убедиться что не одалживает значение из нескольких мест сразу.

use std::cell::RefCell; fn main() { let a = RefCell::new("a".to_string()); // "a" начинает жить тут let f_1 = || a.borrow_mut().push_str(" and x"); // замыкание одалживает немутабельный "a" let f_2 = || a.borrow_mut().push_str(" and y"); // ещё раз одалживает f_1(); // во время выполнения данной лямбды a.borrow_mut() видит, что больше никто не делает тоже самое и позволяет использовать mut значение в блоке лямбды. f_2(); // аналогично для второй. println!("{}", a.borrow()); // в данном случая для вывода нам не нужна мутабельность.
}

Счётчик ссылок Rc

Rc, как можно понять из названия, является просто счётчиком ссылок, который владеет значением, он может одалживать немутабельные ссылки, считает их количество, и, как только количество их обнуляется — уничтожает значение и себя. Данная конструкция знакома во многих языках, и используется в расте, когда, например, мы не можем по каким-то причинам одолжить значение, и возникается необходимость иметь несколько значений-ссылок на одно какое-то одно значение. Получается Rc позволяет как бы скрыто расширять время жизни значения, которое содержится в нём.

Добавлю, что раст умеет автоматически делать deref для структур для которых он определён, это значит, что для работы с Rc, как правило, не надо никаких дополнительных извлечений внутренного значения и мы просто работаем с Rc как со значнием внутри него.

Тут простой пример немного тяжело придумался, попробуем проэмулировать то, что замыкание из примера выше не хочет принимать &T или &String, а хочет именно String:

fn f(x: String) { // именно String, а не одалживание &String println!("{}", x);
} fn main() { let a = "a".to_string(); let f_1 = move || f(a); // если убрать move, тут и ниже ... let f_2 = move || f(a); // ... то компилятор всё равно пожалуется, что не удалось переместить значение в замыкание на этой строке f_1(); f_2(); println!("{}", a);
}

Данная проблема легко бы решалась, если бы мы могли изменить функцию на fn f(x: &String) (или &str), но давайте представим, что мы почему-то не можем использовать &

Воспользуемся Rc

use std::rc::Rc; fn f(x: Rc<String>) { // надо чтобы функция умела работать с Rc println!("{}", x); // К сожалению тут и выше, макрос println не является хорошим примером логики функции необходимой для наших дейстий, так как он умеет печатать и из значения и из ссылки, но для простоты я везде оставил его, ограничив тип параметра функции.
} fn main() { let a_rc = Rc::new("a".to_string()); // наш Rc содержит строку let a_ref_1 = a.clone(); // создаём новое значение-ссылку, увеличиваем счётчик. let a_ref_2 = a.clone(); // ещё раз let f_1 = move || f(a_ref_1); // в данном случае мы перемещаем значение-ссылку let f_2 = move || f(a_ref_2); // аналогично f_1(); f_2(); println!("{}", a_rc); // у нас остался оригинальный Rc со значением. // при окончании блока a_rc уничтожает в себе строку и уничтожается сам.
}

Добавлю последний пример, так как одна из самых частых пар контейнеров, которые можно встретить, это Rc<RefCell>

use std::rc::Rc;
use std::cell::RefCell; fn f(x: Rc<RefCell<String>>) { x.borrow_mut().push_str(" and x"); // из счётчика мы одалживаем мутабельное значение, и если эта процедура не кидает панику, то меняем его.
} fn main() { let a = Rc::new(RefCell::new("a".to_string())); // счётчик ссылок со скрытой мутабельностью let a_ref_1 = a.clone(); let a_ref_2 = a.clone(); let f_1 = move || f(a_ref_1); let f_2 = move || f(a_ref_2); f_1(); f_2(); println!("{}", a.borrow()); // Rc выходит из блока, с ним RefCell и строка
}

Так что я завершаю. Дальше было бы логичным переместить данный tutorial на потокобезопасный аналог Rc — Arc и потом продолжить про Mutex, но потокобезопасность и borrowchecker не расскажешь в одним абзаце, и не ясно нужны ли подобного типа статьи вообще, так как есть официальный расбук.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Анализ кода CUBA Platform с помощью PVS-Studio

Для Java программистов существуют полезные инструменты, помогающие писать качественный код, например, мощная среда разработки IntelliJ IDEA, бесплатные анализаторы SpotBugs, PMD и другие. Всё это уже используется в разработке проекта CUBA Platform, и в этом обзоре найденных дефектов кода я расскажу, ...

[Из песочницы] Подготовка к промышленному производству ДО-РА

1. Транспортировка образцов после ядерной катастрофы на АЭС Фукусима в Японии и задумывался в виде гаджета – персонального дозиметра-радиометра работающего с одноименным ПО – DO-RA. Проект DO-RA DO-RA.com был рождён в марте 2011 г. Soft на любом смартфоне под мобильные ...