22 октября 2021

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

Backend-разработчик со стажем в 15 лет. Увлекаюсь Rust.
Владение и заимствование – ключевая концепция языка Rust и его неоспоримое преимущество. Необходимость освоить эту концепцию является насущной для любого разработчика, а ключом к ней станет Lifetime. Поговорим об этом детально.
🛠 Владение и заимствование в 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);
}
    

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

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

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

В C блоки кода работают немного иначе. Такая же программа на 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
    

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

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

Borrow-checker просто не позволяет нам написать подобную программу на Rust. GCC видит проблему, но все равно компилирует код. Наш пример слишком прост, чтобы он пропустил ошибку (clang программу с такой функцией и вовсе убережет от ошибки сегментирования), однако C и C++ никак не защищают от подобного с точки зрения семантики языка. В простых случаях мы можем положиться на компилятор, но нет языковых механик, которые уберегут нас от проблемы в принципе. На помощь придёт стандартная библиотека 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;
   }
}
    
🛠 Владение и заимствование в 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 и не заставит отлаживать себя в самое неподходящее время. В следующий раз мы заглянем ещё глубже под капот и немножко приоткроем тайну, как компилятор делает магию владения. За пределами этой статьи остались такие важные темы, как автоматическое управление данными в куче и связывание их с переменными на стеке.

Больше полезной информации о популярных языках вы можете найти на нашем телеграм-канале «Библиотека программиста».

Источники

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик C++
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ