Share This
Связаться со мной
Крути в низ
Categories
//🛠 Владение и заимствование в Rust: детально о Lifetime для начинающих и более опытных

🛠 Владение и заимствование в Rust: детально о Lifetime для начинающих и более опытных

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

vladenie i zaimstvovanie v rust detalno o lifetime dlja nachinajushhih i bolee opytnyh f4521ed - 🛠 Владение и заимствование в Rust: детально о Lifetime для начинающих и более опытных

Для кого эта статья Для многих программистов, привыкших к мутабельным состояниям данных в объектно-ориентированой упаковке, Rust становится настоящим откровением. Не удивительно, ведь тут нет сборщика мусора, а безопасность памяти есть. Именно это и делает язык по-настоящему мощным, а его систему типов зубодробительно-сложной. Любой начинающий изучать Rust программист в первую очередь читает Rust Book, который очень точно переведён на русский язык. Это отличный текст: следуя современным требованиям к обучению, он создает впечатление, что все достаточно просто. Сложности начинаются во время практики. Borrow checker заставит ваш мозг всосать весь оставшийся в крови сахар, чтобы понять, почему оно не компилируется.

Значит ли это, что вам придется прочитать сложные тексты на эту тему? Скажем, когда-нибудь – да. Пока эту задачу старается решить наша статья, которая во-первых написана по-русски, а во-вторых содержит некоторую интерпретацию официальной документации, призванную снизить ставшую мемом «Крутую кривую обучения» (Steep learning curve). Для понимания вам нужно хотя бы в общих чертах представлять себе, что такое куча и стек и зачем они нужны. В той или иной степени этот вопрос раскрывают материалы по разным языкам программирования. Также очень важно выучить буквально три пункта правил владения.

В чем проблема?

Это стандартное продающее язык объяснение. Посвященные могут его пропустить.

Глобальная проблема «опасных» языков C и C++ в том, что мы самостоятельно управляем данными в куче, но не имеем способа жестко связать эти данные с переменными в стеке, временем жизни которых управляет компилятор. Значит мы не можем быть уверенными в получении корректных данных по указателю. Получить недействительный указатель очень просто.

Ещё больше проблема усугубляется потребностью писать многопоточный код, который в нескольких экземплярах имеет прямой доступ к общей для всех потоков памяти. Тут важно понимать, что запросы на модификацию данных в памяти могут быть (с точки зрения процессора) не атомарными, а значит может потребоваться несколько инструкций для одной операции. Если же выполняются два потока одновременно, то инструкции процессора, скажем, перемешиваются, и получается бардак, называемый гонкой данных (Data Races), что в свою очередь является неопределенным поведением (Undefined Behaviour, далее UB). Отладка программы в поисках причины UB – чрезвычайно трудоемкий процесс.

Разработчики Rust поняли причины этих проблем и применили практики хороших статических анализаторов кода на C/C++ (но это не точно) для создания языка, который по своей семантике не позволяет программисту отстрелить себе ногу. При этом Rust не создает прослойку между кодом и железом в виде избыточного runtime с garbage collector.

Ссылки

В целом &ссылки/*указатели нужны когда данные: находятся в куче (отдельная тема), или просто ради того, чтобы избежать избыточное копирование данных в стеке.

В Rust есть ровно два типа ссылок (про сырые указатели не говорим):

  • Ссылки на чтение (shared reference): &lnk_name – позволяет только читать данные.
  • Изменяемые (мутабельные) ссылки: &mut lnk_name – позволяет изменять данные.

Ссылки подчиняются двум правилам, называемым правилами заимствования:

  • Ссылки не должны жить дольше чем данные, на которые они ссылаются (Rust Book описывает это так: «все ссылки должны быть действительными»).
  • Мутабельная ссылка должна быть уникальна или, цитируя Rust Book: «в один момент времени может существовать либо одна изменяемая ссылочная переменная, либо любое количество неизменяемых ссылочных переменных». Забегая вперед: на мутабельных ссылках запрещен алиасинг. Англицизм тут нужен для описания конкретного эффекта внезапного изменения значения под ссылкой. Это справедливо не только для области видимости, но и для всей выполняемой ветки программы. Скажем больше, мутабельная ссылка должна быть уникальной и в нескольких потоках, что достигается с помощью Mutex.

Что такое Lifetime?

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

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

Lifetime – именованная область программы, ссылки в которой будут действительными. Эти области могут быть очень сложными, поскольку они соотносятся с ветвлением при выполнении программы. Дадим формальное определение, цитируя Rustomonicon.

Вот прямо так. Не время – область кода. Для нас это абстрактное время, поскольку области кода выполняются последовательно. Lifetime – это как имя самой области, так и метка для ссылки на эту область. Запомним это.

Начнем с того, что время жизни есть не только в Rust. Обычно в C-подобных языках время жизни переменных ограничено функциями (эксперты по стандарту C/C++ поправьте, если что). И в Rust и в С/C++ можно ограничить область видимости переменных синтаксисом блоков “{…}”, однако в Rust блок гарантировано определяет как «долго» переменная проживет на стеке, в отличие от C/C++, где блок служит для семантического ограничения доступа к переменным.

Возьмём простой пример:

         fn main() {    let num_ref;    {        let num = 4;        num_ref = #    } // тут переменная num будет уничтожена, выйдя за пределы блока.    // Однако num_ref ссылается на её адрес,    // но пока все в порядке, наличие висящей ссылки не проблема      // а вот попытка разыменовать такую ссылку - проблема, т.е. UB    println!("say number {}", *num_ref); }     

При попытке собрать эту программу компилятор выдаст ошибку

vladenie i zaimstvovanie v rust detalno o lifetime dlja nachinajushhih i bolee opytnyh 39c4c03 - 🛠 Владение и заимствование в Rust: детально о Lifetime для начинающих и более опытных

Попробуем смоделировать на C что могло получиться, если бы Rust нас не защитил.

В C блоки кода работают немного иначе. Такая же программа на C будет работать корректно в силу того, что у C нет потребности при выходе из блока выкинуть данные из стека. Потому для ограничения области видимости переменной воспользуемся функцией, что гарантирует ликвидацию переменной в стека при выходе за пределы блока (как и в Rust).

         #include "stdio.h"   int* get_num_ptr() {  int num = 4;  return # }   int main() {  int* num_ref = get_num_ptr();  printf("say num: %d", *num_ref); }      

Скомпилируем и исполним:

         gcc src/bin/lifetime_too_short.c -o lifetime_too_short_c && ./lifetime_too_short_c     

Получим следующий результат:

vladenie i zaimstvovanie v rust detalno o lifetime dlja nachinajushhih i bolee opytnyh c717b1e - 🛠 Владение и заимствование в Rust: детально о Lifetime для начинающих и более опытных

Borrow-checker просто не позволяет нам написать подобную программу на Rust. GCC видит проблему, но все равно компилирует код. Наш пример слишком прост, чтобы он пропустил ошибку (clang программу с такой функцией и вовсе убережет от ошибки сегментирования), однако C и C++ никак не защищают от подобного с точки зрения семантики языка. В простых случаях мы можем положиться на компилятор, но нет языковых механик, которые нас уберегут от проблемы в принципе. На помощь придёт стандартная библиотека C++, хотя даже с ней можно отстрелить себе что угодно.

Мутабельные ссылки и модель алиасинга

Этот вопрос мы рассмотрим в контексте однопоточного исполнения без прерываний. Асинхронное программирование выходит за пределы темы.

Возьмем пример из Rust Book:

         fn big_problem() {  let mut s = String::from("hello");  let r1 = &s; // no problem  let r2 = &s; // no problem  let r3 = &mut s; // BIG PROBLEM  println!("{}, {}, and {}", r1, r2, r3); }      

Видно что это прямое нарушение правил заимствования. Rust Book весьма лаконично описывает что такое мутабельный алиасинг фразой: «Пользователи неизменяемой ссылки не ожидают внезапного изменения значения, на которые она указывает!» Однако на этом примере совершенно не очевидно, почему это плохо.

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

         fn compute(input: &u32, output: &mut u32) {  if *input > 10 {      *output = 1;  }  if *input > 5 {      *output *= 2;  }  // помните что переменная `output` == `2` если `input > 10` }      

При логичном её использовании результат будет ожидаемым – в output будет положено число 2:

         let input = 20; let mut output = 0; compute(&input, &mut output); // в `output` положит 2     

Однако в функции compute() есть один интересный эффект. Что если мы передадим в качестве обоих аргументов ссылки на один и тот же кусок памяти?

         let mut num = 20; compute(&num, &mut num);     

В результате после первого блока if значение input изменится и второй блок if просто не сработает, поскольку input тоже изменился и содержит значение менее 5. Это и было названо в Rust Book внезапным изменением значения, поскольку наше внимание приковано к именам переменных и подразумевает, что эти данные разные.

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

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

Rust так делать не позволяет и пытается оптимизировать функцию:

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

Компилятор пожелает сделать код функции, который можно выразить так:

         fn compute_on_stack(input: &u32, output: &mut u32) {    let mut cache = *output;    if *input > 10 {      cache = 1;    }    if *input > 5 {        cache *= 2;    }    *output = cache; }     

Rust может позволить себе такую оптимизацию, так как точно знает, что input и output указывают на разные значения, потому можно все разыменования заменить на обращение к копии данных в локальном кеше в регистре. Либеральные C и C++ такого позволить себе не могут: вдруг программист рассчитывает на мутабельный алиасинг.

Lifetime как блок и как тип

Как было сказано выше, lifetime – это область кода, в которой живет переменная/данные и эти области компилятор должен разметить, чтобы применить ограничения и выявить проблемы времени жизни. В Rust в night-сборках даже есть специальный синтаксис, который позволяет применять lifetime-метки в блоках явным образом. Этот синтаксис поможет нам «рассахаривать» исходный код в представление, которое выводит компилятор в итоге.

Например, этот код:

         let x = 0; let y = &x; let z = &y;     

можно выразить так:

         'a: {    let x: i32 = 0;    'b: {        let y: &'b i32 = &'b x;        'c: {            let z: &'c &'b i32 = &'c y;        }    } }     

Lifetime всегда указывается через апостроф. Сама метка ничего не говорит нам об относительных размерах lifetime (за исключением ‘static). Для нас имя времени жизни служит только признаком равенства или неравенства, остальное – забота компилятора. Однако вложенность блоков явно показывает, какой lifetime длиннее.

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

         let x = 0; let z; let y = &x; z = y;     

Передача ссылки за пределы scope заставит Rust вывести более длинные lifetime:

         'a: {     let x: i32 = 0;     'b: {         let z: &'b i32;         'c: {             // Для &y используется 'b вместо 'c             // поскольку эта ссылка передана             // в переменную из scopa-а 'b             let y: &'b i32 = &'b x;             z = y;         }     }   }     

Функции и структуры могут содержать ссылки, и тогда их сигнатуры приобретут генеричный вид. Имя lifetime в таком случае является неотъемлемым входным параметром дженерика как и типовый параметр. Даже если мы не будем прописывать параметр(ы) lifetime явно, они в любом случае будут выведены компилятором сразу после появления в сигнатуре символа “&”.

         fn as_str(data: &u32) -> &str {    let s = format!("{}", data);    &s }     

Можно выразить так:

         fn as_str<'a>(data: &'a u32) -> &'a str {    'b: {        let s = format!("{}", data);        return &'a s;    } }     

vladenie i zaimstvovanie v rust detalno o lifetime dlja nachinajushhih i bolee opytnyh 2b9a2f8 - 🛠 Владение и заимствование в Rust: детально о Lifetime для начинающих и более опытных

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

         fn to_string(data: &u32) -> String { 	format!("{}", data) }     

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

Попробуем «рассахарить» ещё один кусок кода, который отобразит проблему мутабельного алиасинга:

         let mut data = vec![1, 2, 3]; let x = &data[0]; data.push(4); println!("{}", x);     

Переменная x – ссылка на часть вектора. Так как вектор – это умный указатель, то при добавлении элемента хранилище в куче может быть перераспределено вне зависимости от нас и все ссылки на старые данные станут недействительны (ошибка сегментирования и/или UB). Однако компилятору не надо ничего знать про то, что x является ссылкой на часть вектора или что такое вектор, и как он оперирует данными в куче. Компилятору достаточно того, что нарушено правило заимствования.

Компилятор выведет следующий код из которого сразу видно проблему с lifetime:

         'a: {     let mut data: Vec<i32> = vec![1, 2, 3];     'b: {         // для переменной x выводится lifetime 'b         // (так как в этом scope-е происходит         // обращение к x в println!)         let x: &'b i32 = Index::index::<'b>(&'b data, 0);         'c: {             Vec::push(&'c mut data, 4);         }         println!("{}", x);     } }     

Таким образом Rust видит, что x должен прожить время ‘b, чтобы быть напечатанным в println!(), что и отражено в выведенной сигнатуре функции Index::index, которая в качестве входного аргумента дженерика принимает lifetime ‘b. Ниже мы пытаемся заимствовать data с меньшим lifetime, на что и ругается компилятор. Если просто убрать println!(“{}”, x), все будет работать корректно, поскольку висящий указатель/ссылка – не проблема. Проблема – разыменовывание такой ссылки. На самом деле это может быть проблемой, если мы пишем свой деструктор, но об этом поговорим в другой статье.

Замалчивание Lifetime (Elision)

До появления версии Rust 1.0 сигнатуры функций со ссылками приходилось явно аннотировать lifetime. Позднее стало понятно, что компилятор сам может во многом разобраться и вывести все за нас, что сделает программы короче и более читаемыми.

Эта фича и называется замалчиванием lifetime или Elision. Это плюс, только вот правила выведения lifetime обратно в сингратуру функций достаточно жесткие – имеет смысл знать их наизусть. А ещё они не описаны в Rust Book явным образом.

Исправим это досадное недоразумение.

Lifetime может входить в сигнатуры тремя способами:

  • &'a T – ссылка на переменную типа T.
  • &'a mut T – мутабельная ссылка на переменную T.
  • T<'a> – сигнатура типа T в случае, если поля структур или сигнатуры методов трейтов содержат ссылки.

То есть у нас есть входные и выходные lifetime, что было видно и в предыдущем разделе. Поговорим подробнее об их взаимосвязи. Обращаю внимание, что если вам не подходит выведение замалчиваемых (elided) lifetime, вы всегда можете определить их явно.

Правила следующие (их надо запомнить):

  • Каждый замалчиваемый (elided) lifetime в сигнатуре функции уникален, т.е. в функции fn do_something(a: &str, b: &str) у аргументов a и b будут разные lifetime.
  • Если в функции только один ссылочный аргумент с замалчиваемым или явным lifetime, то все выходные lifetime будут ему равны, т.е. fn do_something(s: &’a str) -> (&’a str, &’a str) в нашем случае вернет кортеж с двумя ссылками, но это может быть и структура с более чем одним ссылочным полем
  • Если один из аргументов функции &self или &mut self, то все выходные замалчиваемые lifetime будут выведены равными lifetime ссылки на self.
  • В противном случае Rust не сможет вывести lifetime и заставит вас сделать это явным образом

Давайте теперь посмотрим на примерах:

         fn print(s: &str);                                      // молча да fn print<'a>(s: &'a str);                               // явно   fn debug(lvl: usize, s: &str);                          // молча fn debug<'a>(lvl: usize, s: &'a str);                   // явно   fn substr(s: &str, until: usize) -> &str;               // молча fn substr<'a>(s: &'a str, until: usize) -> &'a str;     // явно   fn get_str() -> &str;                                   // так нельзя   fn frob(s: &str, t: &str) -> &str;                      // так нельзя   fn get_mut(&mut self) -> &mut T;                        // молча fn get_mut<'a>(&'a mut self) -> &'a mut T;              // явно   fn args<T: ToCStr>(&mut self, args: &[T]) -> &mut Command                  // молча fn args<'a, 'b, T: ToCStr>(&'a mut self, args: &'b [T]) -> &'a mut Command // явно   fn new(buf: &mut [u8]) -> BufWriter;                    // молча fn new<'a>(buf: &'a mut [u8]) -> BufWriter<'a>          // явно     

Вывод

Начав читать эту статью, вы хотели избавиться от проблем. На деле все прозаично: надо понять, как в действительности работает владение и заимствование в Rust, а также воспринимать ругающийся компилятор как великое благо. Да, он заставит вас переписать код, зато этот код не выстрелит вам в ногу, руку или в голову 31 декабря в 23:30 и не заставит отлаживать себя в самое неподходящее время. В следующий раз мы заглянем ещё глубже под капот и немножко приоткроем тайну, как компилятор делает магию владения. За пределами этой статьи остались такие важные темы, как автоматическое управление данными в куче и связывание их с переменными на стеке.

  • 11 views
  • 0 Comment

Leave a Reply

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

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.

Связаться со мной
Close