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

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


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


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


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


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


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


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

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


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


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


    fn main() {
       {  // это начало блока
         let a = "a".to_string();  // <- тут создалось "a"
         let b = 100;  // <- тут создалось "b"
         // <- тут умирает b
         // <- тут умирает a
       } // это конец блока
       // тут уже нет ни "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 не расскажешь в одним абзаце, и не ясно нужны ли подобного типа статьи вообще, так как есть официальный растбук. Так что я завершаю.

    Поделиться публикацией

    Похожие публикации

    Комментарии 14

      +5
      Как-то после lifetimes в заголовке ожидалось, что в статье будет про явное указание времен жизни ссылок. Статья больше напоминает недописанный черновик: много синтаксических ошибок, довольно многословные примеры и такое ощущение, что конец отсутствует. Можно причесать — и получится неплохая статья, но в текущем виде ее читать тяжеловато. (
        +2
        NLL тоже не особо раскрыт
          +1

          C NLL все просто — если код работает без NLL — то с ним то уж точно работать будет. Единственное отличие — переменная уничтожается не в конце блока кода, а после последнего своего использования.

            +1
            Собственно этот нюанс в статье отсутствует, хотя и вполне относится к теме.
            +1
            так есть же, но не явно :)
            «современный раст делает это автоматически лучше чем в старые времена»
              +1

              Это разве не про lifetime elision?

                +1

                Да, вы правы.

            +1

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

              +1

              Честно говоря не сказал бы, что оно часто используется.

                +1

                С текущим массовым увлечением статической диспетчеризацией — достаточно часто.

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

                +3

                Так не сработает. Рано или поздно пользователь наткнется на сигнатуру

                fn epic<'a, 'b: 'a>(aa: &'a A, bb: &'b B)
                и придет в замешательство. А с этим примером путь только один — в офдоку. В которой придется несколько часов посидеть — лучше и компактней нигде о вж не рассказано.
                  +2
                  Отчасти согласен. Рано или поздно пользователь столкнётся не только с примером что вы написали, но и с огромным количеством других непонятных вещей, типа матча и тд, которые тоже надо бы раскрывать. У меня изначально вообще была идея сделать что-то типа FAQ: проблема — решение, но, показалось, что это не очень подходящий формат для статьи и получилось то, что получилось.

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

          Самое читаемое