Заимствование и время существования в Rust

Представляю вашему вниманию перевод статьи «Rust Borrow and Lifetimes» из блога Артура Ляо (Arthur Liao), инженера Yahoo!

Rust — это новый язык программирования, находящийся в активной разработке с версии 1.0. Я могу написать другой блог о Rust и том, почему он крут, но сегодня я сфокусируюсь на его системе заимствования и времени существования, которая запутывает многих новичков, в том числе и меня самого. Данный пост предполагает, что у вас есть базовое понимание Rust. Если же нет, вы можете сперва прочитать само Руководство и Руководство по указателям.

Владение ресурсами и заимствование


В Rust безопасность по памяти обеспечивается без сбора мусора путём использования усложнённой системы заимствования. Имеется как минимум один владелец (owner) для любого ресурса, который занимается освобождением своих ресурсов. Вы можете создать новые биндинги, чтобы обратиться к ресурсу, использующему & или &mut, которые называются заимствованием (borrow) и изменяемым заимствованием (mutable borrow). Компилятор следит за должным поведением всех владельцев (owners) и заёмщиков (borrowers).

Копирование и перемещение


Перед тем, как перейти к системе заимствования, нам нужно знать, как методы copy и move обрабатываются в Rust. Этот ответ из StackOverflow просто необходимо прочитать. В целом, в присваиваниях и в функции они вызываются:

1. Если значение копируемо ( с участием лишь примитивных типов, без участия ресурсов, например обработки памяти или файла), компилятор по умолчанию копирует.
2. В противном случае, компилятор перемещает (передает) владение (ownership) и делает недействительным оригинальный биндинг.

Вкратце, pod (читаемые старые данные) => copy, non-pod (линейные типы) => move.

Тут есть несколько дополнительных замечаний для справки:

* Метод Rust copy похож на Си. Каждое использование по значению является побайтовым копированием (теневой метод memcpy copy) вместо семантического копирования или клонирования.
* Чтобы сделать структуру pod некопируемой, вы можете использовать поле маркера NoCopy или реализовать типаж Drop.

После перемещения, владение передаётся следующему владельцу.

Освобождение ресурса


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

1. Владелец окажется вне области или
2. Владение биндингом изменяется (тем самым, оригинальный биндинг становится void)

Привилегии и ограничения владельца (owner) и заёмщика (borrower)


Этот раздел основывается на Руководстве по Rust с упоминанием методов copy и move в части привилегий.

Владелец имеет некоторые привилегии. И может:

1. Контролировать деаллокацию ресурса
2. Занимать ресурс неизменяемо (множественные заимствования) или изменяемо (эксклюзивно) и
3. Передавать владение (с перемещением).

Владелец также имеет некоторые ограничения:

1. В процессе заимствования, владелец не может (а) изменять ресурс или (б) занимать его в измененном виде.
2. В процессе изменяемого заимствования, владелец не может (а) иметь доступ к ресурсу или (б) занимать его.

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

1. Заёмщик может распределить (копировать) указатель неизменяемое заимствование (immutable borrow)
2. Изменяемый заёмщик может передавать (перемещать) изменяемое заимствование. (Заметьте, что изменяемая ссылка (mutable reference) перемещена).

Примеры кода


Довольно разговоров. Давайте взглянем на какой-нибудь код (Вы можете запустить код Rust по адресу play.rust-lang.org). Во всех нижеследующих примерах мы используем «struct Foo», структуру Foo, которая не является копируемой, поскольку содержит упакованное (динамически распределяемое) значение. Использование некопируемых ресурсов ограничивает возможности операций, что является хорошей идеей на этапе изучения.

К каждому образцу кода также даётся «диаграмма области» для иллюстрации областей владельца, заёмщиков и т. д. Фигурные скобки в строке заголовка совпадают с фигурными скобками в самом коде.

Владелец не может иметь доступ к ресурсу в процессе изменяемого заимствования

Нижеследующий код не скомпилируется, если мы не раскоментируем последнюю строку «println:»:

struct Foo {
    f: Box<int>,
}

fn main() {
    let mut a = Foo { f: box 0 };
    // изменяемое заимствование
    let x = &mut a;
    // ошибка: не могу заимствовать `a.f` как изменяемое, т. к. `a` уже заимствовано как изменямое
    // println!("{}", a.f);
}
           { a x * }
владелец a   |_____|
заёмщик  x     |___| x = &mut a
доступ a.f       |   ошибка

Это нарушает ограничение владельца #2 (а). Если мы поместим «let x = &mut a;» во вложенный блок, заимствование завершается перед строкой println! и это могло бы сработать:
fn main() {
    let mut a = Foo { f: box 0 };
    {
        // изменяемое заимствование 
        let x = &mut a;
        // здесь изменяемое заимствование завершается
    }
    println!("{}", a.f);
}
           { a { x } * }
владелец a   |_________|
заёмщик  x       |_|     x = &mut a
доступ a.f           |   OK

Заёмщик может перемещать изменяемое заимствование в новый заёмщик

Этот код иллюстрирует привилегии заёмщика #2: изменяемый заёмщик x может передавать (перемещать) изменяемое заимствование в новый заёемщик y.

fn main() {
    let mut a = Foo { f: box 0 };
    // изменяемое заимствование
    let x = &mut a;
    // переместить изменяемое заимствование в новый заёмщик y
    let y = x;
    // ошибка: использование перемещённого значения: `x.f`
    // println!("{}", x.f);
}
           { a x y * }
владелец a   |_______|
заёмщик  x     |_|     x = &mut a
заёмщик  y       |___| y = x
доступ x.f         |   ошибка

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

Область заимствования


Всё становится интересным, если мы передадим сюда ссылки (& и &mut), и тут многие новички начинают путаться.

Время существования



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

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

& = заимствование


Пару слов о заимствовании. Во-первых, просто запомните, что & = заимствование, а &mut = изменяемое заимствование. Где бы вы не увидели символ & — это заимствование.

Во-вторых, если символ & показывается в каждой структуре (в её поле) или в функции/замыкании (в его возвращаемом типе или захваченным ссылкам), то такая структура/функция/замыкание является заёмщиком, и к ней применяются все правила заимствования.

В-третьих, для каждого заимствования имеется владелец и одиночный заёмщик или множественные заёмщики.

Расширение области займа


Несколько слов об области займа. Во первых, область займа:
— это область где заимствование эффективно, и
— заёмщик может расширить область займа (см ниже).

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

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

Формула займа


Теперь, у нас есть формула займа:
область ресурсов >= область займа = объединение областей всех заёмщиков

Пример кода


Давайте взглянем на некоторые примеры расширения области займа. Структура struct Foo та же самая как и прежде:

fn main() {
    let mut a = Foo { f: Box::new(0) };
    let y: &Foo;
    if false {
        // займ
        let x = &a;
        // поделиться займом с заемщиком y, следовательно расширив займы
        y = x;
    }
    // ошибка: не могу присвоить в `a.f`, потому, что он заимствован
    // a.f = Box::new(1);
}

               { a { x y } * }
    ресурс  a   |___________|
    заёмщик x       |___|     x = &a
    заёмщик y         |_____| y = x
область займа       |=======|
изменение a.f             |   ошибка

Даже несмотря на то, что заём происходит внутри if блока, и заёмщик х выходит за рамки после if блока, он расширил сферу заимствования через присваивания y = x;, так что есть два заёмщика: х и у. В соответствии с формулой заёма, область заёма является объединением заёмщика х и заёмщика у, которое находится между первым заёмом let x = &a; и до конца основного блока. (Обратите внимание, что связывание let y: &Foo; не заёмщик)

Вы, возможно, заметили, что, блок if никогда не будет выполнен, так как условие всегда ложно, но компилятор всё ещё запрещает владельцу ресурса `a` доступ к ресурсу. Это потому, что все проверки займа происходят во время компиляции, во время выполнения ничего не сделаешь.

Заимствование нескольких ресурсов


До сих пор мы сосредоточивались только на заимствованиях из одного ресурса. Может ли заёмщик брать несколько ресурсов? Конечно! Например, функция может принимать две ссылки и возвращать одного из них в зависимости от определенных критериев, например, кто из ссылок больше другого:

fn max(x: &Foo, y: &Foo) -> &Foo

Функция max возвращает указатель &, следовательно, это заемщик. Возвратный результат может быть любым из входящих ссылок, поэтому он заимствует два ресурса.

Именованная область займа


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

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

fn max<'a>(x: &'a Foo, y: &'a Foo) -> &'a Foo {
    if x.f > y.f { x } else { y }
}
  
(Все ресурсы и заёмщики сгруппированы в области займа 'a'.)
                  max( {   } ) 
       ресурс *x <-------------->
       ресурс *y <-------------->
область займа 'a <==============>
       заёмщик x        |___|
       заёмщик y        |___|
возвращаемое значение     |___|   обратно к вызываемому

В этой функции у нас есть одна область займа 'a' и три заемщика: два входных параметра и возвращаемый функцией результат. Вышеупомянутая формула заимствования всё ещё ​применяется, но теперь каждый заимствованый ресурс должен удовлетворять формуле. Смотрите пример ниже.

Пример кода


Давайте использовать функцию max в следующем коде, чтобы выбрать самое больше из a и b:

fn main() {
    let a = Foo { f: Box::new(1) };
    let y: &Foo;
    if false {
        let b = Foo { f: Box::new(0) };
        let x = max(&a, &b);
        // ошибка: `b` не живет достаточно долго
        // y = x;
    }
}

               { a { b x (  ) y } }
     ресурс a   |________________| успех
     ресурс b       |__________|   провал
область займа         |==========|
временный заёмщик        |_|       &a
временный заёмщик        |_|       &b
    заёмщик x         |________|   x = max(&a, &b)
    заёмщик y                |___| y = x

До let x = max(&a, &b); всё хорошо, потому, что &a и &b — это временные ссылки, которые действительны только в выражении, а третий заёмщик х заимствует два ресурса (либо a либо b но с проверкой заёмщика, если заимствованы оба) до конца блока if, таким образом, область займа находится в let x = max(&a, &b); до конца блока if. Ресурсы a и b действительны по всей области займа, следовательно, удовлетворяют формуле займа.

Теперь, если мы раскомментируем последнее значение y = x;, y станет четвертым заёмщиком, а область займа увеличится до конца основного блока, в результате чего ресурс b провалить тест формулы займа.

Структура как заёмщик


В дополнение к функциям и замыканиям, структура может также занимать несколько ресурсов, сохраняя несколько ссылок в своей области(ях). Посотрим на пример ниже, и как применяется формула займа. Давайте использовать структуру Link для хранения ссылки(неизменяемый займ(immutable borrow)):

struct Link<'a> {
    link: &'a Foo,
}

Структура заимствует несколько ресурсов


Даже только с одним полем, структура Link может занять несколько ресурсов:

fn main() {
    let a = Foo { f: Box::new(0) };
    let mut x = Link { link: &a };
    if false {
        let b = Foo { f: Box::new(1) };
        // ошибка: `b` не живет достаточно долго
        // x.link = &b;
    }
}

             { a x { b * } }
    ресурс a   |___________| успех
    ресурс b         |___|   провал
область займа    |=========|
   заёмщик x     |_________| x.link = &a
   заёмщик x           |___| x.link = &b

В приведенном выше примере, заёмщик х заимствовует ресурсы от владельца a, и область займа идет до конца основного блока. Всё идёт нормально. Если мы раскомментируем последнюю строку x.link = &b;, x также попытается заимствовать ресурс у владельца b, и тогда ресурс b провалит тест на формулу займа.

Функция для расширения области займа без возвращаемого значения


Функция без возвращаемого значения может также расширить область займа через его входные параметры. Например, функция store_foo принимает изменяемую ссылку на Link, и сохраняет в нее ссылку Foo(immutable borrow):

fn store_foo<'a>(x: &mut Link<'a>, y: &'a Foo) {
    x.link = y;
}

В следующем коде, заимствованные ресурсы овладели ресурсами; Структура Link изменяемо ссылается на заемщик х (т.е. *х является заемщиком); Область займа идет до конца основного блока.

fn main() {
    let a = Foo { f: Box::new(0) };
    let x = &mut Link { link: &a };
    if false {
        let b = Foo { f: Box::new(1) };
        // store_foo(x, &b);
    }
}

  
               { a x { b * } }
      ресурс a   |___________| успех
      ресурс b         |___|   провал
область займа      |=========|
   заёмщик *x     |_________| x.link = &a
   заёмщик *x           |___| x.link = &b

Если мы раскомментирем последнюю строку store_foo(x, &b); функция попытается хранить &b в x.link, делая ресурс b другим заимствованным ресурсом и провалит тест формулы займа, так как область ресурса b не покрывает всю область займа.

Несколько областей займа


Функция может иметь несколько именованных областей займа. Например:
fn superstore_foo<'a, 'b>(x: &mut Link<'a>, y: &'a Foo,
                          x2: &mut Link<'b>, y2: &'b Foo) {
    x.link = y;
    x2.link = y2;
}

В этой (вероятно, не очень полезной) функции участвуют две разрозненные области займа. Каждая область займа будет иметь свою собственную формулу заимствования.

Почему время существования сбивает с толку


Наконец, я хочу объяснить, почему я думаю, что термин время существования, используемый системой займа Rust’а вносит путаницу (и, таким образом, избежать использования термина в этом блоге).

Когда мы говорим о займе, есть три вида «времени существования»:

А: время существования владельца ресурса (или владеющий/заимствованный ресурс)
В: «время существования» всего займа, т.е. с первого займа и до возвращения
С: время существования отдельного заёмщика или заимствованного указателя

Когда кто-то говорит о термине «время существования», он может иметь ввиду любое из выше перечисленных. Если участвуют ещё и несколько ресурсов и заёмщиков, то всё становится еще более запутанным. Например, что делает «время жизни с именем» в объявлении функции или структуры? Означает ли это А, В или С?

В нашем предыдущей функции max:

fn max<'a>(x: &'a Foo, y: &'a Foo) -> &'a Foo {
    if x.f > y.f { x } else { y }
}

Что означает время существования 'a'? Это не должно быть А, поскольку два ресурса задействованы и имеют разные времена существования. Это не может быть и С, потому, что есть три заёмщика: х, y и возвращаемое значение функции и все они имеют разные времена жизни. Означает ли это B? Вероятно. Но вся область заёма не является конкретным объектом, как оно может иметь «время существования»? Назвать это временем существования — значит заблуждаться.

Кто-то может сказать, что это означает минимальные требования времени существования для заимствованных ресурсов. В некоторых случаях, это может иметь значение, однако как мы можем называть их «временем существования»?

Понятие собственности/заимствования само по себе сложное. Я бы сказал, что путаница в том, что даёт «время жизни», делает освоение еще более непонятным.

P.S. С использованием A, B и C, определенный выше, формула займа становится:

    A >= B = C1 U C2 U … U Cn

Изучение Rust стоит вашего времени!


Несмотря на то, что изучение займа и овладения занимает много времени, это интересно изучать. Rust попытается добиться безопасности без сбора мусора, и он до сих пор делает это очень хорошо. Некоторые люди говорят, что освоение Haskell изменяет стиль вашего программирования. Я думаю, что освоение Rust тоже стоит вашего времени.

Надеюсь, что этот пост вам поможет.

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

    +6
    Стоит отметить, что эта статья немного устарела. В Rust 1.0 и последующих версиях (и даже немного раньше 1.0) по умолчанию значения не копируются, а перемещаются, даже если тип содержит только Copy-данные внутри себя. Чтобы тип был копируем, нужно явно реализовать трейт Copy для него, например, с помощью #[deriving(Copy, Clone)] (реализовать также Clone нужно потому что Copy наследует Clone). Поэтому маркера NoCopy больше не существует.
      0
      Copy наследует Clone

      Скорее не «наследует», а «требует», потому что это типаж, а не класс, а типажи не наследуют, а накладывают ограничения по типы, для которых их реализуют. И если бы Copy наследовал Clone, то выходило бы, что явно говорить, что надо реализовать Clone, было бы не нужно (ведь его функционал уже включён в Copy), а он именно требует, поэтому для каждой реализации Copy надо явно реализовать Clone.
        0
        Я в курсе, что «наследование» трейтов на самом деле не наследование в смысле наследования классов/интерфейсов, например, в Java. Но это официальная терминология, см. например, здесь:
        Traits may inherit from other traits.

        (выделение моё)

        Поэтому, так как Copy объявлен как
        trait Copy : Clone {}
        

        вполне корректно говорить, что Copy наследует Clone.
          0
          Хм, возможно. Просто я как-то привык к определённому значению слова «наследование» в ООП, а тут оно имеет несколько иное значение, поэтому пришлось мысленно выбирать другой термин для более точного понимания, так что я сам привык думать про эту концепцию в расте как о «требовании» или «ограничениях на тип», чтобы не путать с наследованием как его понимают в классическом ООП.

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

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