🛠 Владение и заимствование в 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 есть ровно два типа ссылок (про сырые указатели не говорим): Ссылки подчиняются двум правилам, называемым правилами заимствования: Вспомним первое правило ссылок: данные должны жить дольше чем ссылки на них, и именно эту гарантию дает язык Rust с помощью Lifetime. Любая переменная – это память на стеке. Ссылка на другую переменную (или на данные в куче) – тоже переменная в стеке. Если ссылка проживет дольше чем сами данные, то при попытке обратиться через неё (разыменовать), произойдет обращение к освобожденной памяти – классическая ошибка сегментирования. Lifetime – именованная область программы, ссылки в которой будут действительными. Эти области могут быть очень сложными, поскольку они соотносятся с ветвлением при выполнении программы. Дадим формальное определение, цитируя Rustomonicon. Вот прямо так. Не время – область кода. Для нас это абстрактное время, поскольку области кода выполняются последовательно. Lifetime – это как имя самой области, так и метка для ссылки на эту область. Запомним это. Начнем с того, что время жизни есть не только в Rust. Обычно в C-подобных языках время жизни переменных ограничено функциями (эксперты по стандарту C/C++ поправьте, если что). И в Rust и в С/C++ можно ограничить область видимости переменных синтаксисом блоков “{…}”, однако в Rust блок гарантировано определяет как «долго» переменная проживет на стеке, в отличие от C/C++, где блок служит для семантического ограничения доступа к переменным. Возьмём простой пример: При попытке собрать эту программу компилятор выдаст ошибку Попробуем смоделировать на C что могло получиться, если бы Rust нас не защитил. В C блоки кода работают немного иначе. Такая же программа на C будет работать корректно в силу того, что у C нет потребности при выходе из блока выкинуть данные из стека. Потому для ограничения области видимости переменной воспользуемся функцией, что гарантирует ликвидацию переменной в стека при выходе за пределы блока (как и в Rust). Скомпилируем и исполним: Получим следующий результат: Borrow-checker просто не позволяет нам написать подобную программу на Rust. GCC видит проблему, но все равно компилирует код. Наш пример слишком прост, чтобы он пропустил ошибку (clang программу с такой функцией и вовсе убережет от ошибки сегментирования), однако C и C++ никак не защищают от подобного с точки зрения семантики языка. В простых случаях мы можем положиться на компилятор, но нет языковых механик, которые нас уберегут от проблемы в принципе. На помощь придёт стандартная библиотека C++, хотя даже с ней можно отстрелить себе что угодно. Этот вопрос мы рассмотрим в контексте однопоточного исполнения без прерываний. Асинхронное программирование выходит за пределы темы. Возьмем пример из Rust Book: Видно что это прямое нарушение правил заимствования. Rust Book весьма лаконично описывает что такое мутабельный алиасинг фразой: «Пользователи неизменяемой ссылки не ожидают внезапного изменения значения, на которые она указывает!» Однако на этом примере совершенно не очевидно, почему это плохо. Давайте разберем другой код, в котором это эффект в глаза не бросается, но покажет, почему мутабельная ссылка должна быть уникальной: При логичном её использовании результат будет ожидаемым – в Однако в функции В результате после первого блока Пока здесь сложно разглядеть что-то совсем разрушительное, более того, есть вероятность, что некоторые программы могут эксплуатировать такое поведение во благо (но это не точно). Для нас важен факт, что большинство языков с их либеральным подходом формально разрешают мутабельный алиасинг. Теперь представьте, что таких блоков в функции много и есть много функций, которые рассчитывают на мутабельный алиасинг. Скорее всего вместе с внезапным изменением значений неизменяемых ссылок вы получите внезапный неожиданный результат. Rust так делать не позволяет и пытается оптимизировать функцию: Компилятор пожелает сделать код функции, который можно выразить так: Rust может позволить себе такую оптимизацию, так как точно знает, что Как было сказано выше, lifetime – это область кода, в которой живет переменная/данные и эти области компилятор должен разметить, чтобы применить ограничения и выявить проблемы времени жизни. В Rust в night-сборках даже есть специальный синтаксис, который позволяет применять lifetime-метки в блоках явным образом. Этот синтаксис поможет нам «рассахаривать» исходный код в представление, которое выводит компилятор в итоге. Например, этот код: можно выразить так: Lifetime всегда указывается через апостроф. Сама метка ничего не говорит нам об относительных размерах lifetime (за исключением ‘static). Для нас имя времени жизни служит только признаком равенства или неравенства, остальное – забота компилятора. Однако вложенность блоков явно показывает, какой lifetime длиннее. Как видно из этого примера, создание переменной порождает вложенный блок с более коротким lifetime. Порядок создания переменных в стеке важен для соблюдения обратного порядка их уничтожения. Потому lifetime для следующего кода будут выведены несколько иначе: Передача ссылки за пределы scope заставит Rust вывести более длинные lifetime: Функции и структуры могут содержать ссылки, и тогда их сигнатуры приобретут генеричный вид. Имя lifetime в таком случае является неотъемлемым входным параметром дженерика как и типовый параметр. Даже если мы не будем прописывать параметр(ы) lifetime явно, они в любом случае будут выведены компилятором сразу после появления в сигнатуре символа “&”. Можно выразить так: Вопрос почему функция должна вернуть ссылку с lifetime не меньшим чем входящий, в целом, должен быть уже очевидным (мы разберем его в деталях). Естественно, данную функцию будет правильно написать так: Поскольку Попробуем «рассахарить» ещё один кусок кода, который отобразит проблему мутабельного алиасинга: Переменная Компилятор выведет следующий код из которого сразу видно проблему с lifetime: Таким образом Rust видит, что До появления версии Rust 1.0 сигнатуры функций со ссылками приходилось явно аннотировать lifetime. Позднее стало понятно, что компилятор сам может во многом разобраться и вывести все за нас, что сделает программы короче и более читаемыми. Эта фича и называется замалчиванием lifetime или Elision. Это плюс, только вот правила выведения lifetime обратно в сингратуру функций достаточно жесткие – имеет смысл знать их наизусть. А ещё они не описаны в Rust Book явным образом. Исправим это досадное недоразумение. Lifetime может входить в сигнатуры тремя способами: То есть у нас есть входные и выходные lifetime, что было видно и в предыдущем разделе. Поговорим подробнее об их взаимосвязи. Обращаю внимание, что если вам не подходит выведение замалчиваемых (elided) lifetime, вы всегда можете определить их явно. Правила следующие (их надо запомнить): Давайте теперь посмотрим на примерах: Начав читать эту статью, вы хотели избавиться от проблем. На деле все прозаично: надо понять, как в действительности работает владение и заимствование в Rust, а также воспринимать ругающийся компилятор как великое благо. Да, он заставит вас переписать код, зато этот код не выстрелит вам в ногу, руку или в голову 31 декабря в 23:30 и не заставит отлаживать себя в самое неподходящее время. В следующий раз мы заглянем ещё глубже под капот и немножко приоткроем тайну, как компилятор делает магию владения. За пределами этой статьи остались такие важные темы, как автоматическое управление данными в куче и связывание их с переменными на стеке.Ссылки
&lnk_name
– позволяет только читать данные.&mut lnk_name
– позволяет изменять данные.Что такое Lifetime?
fn main() { let num_ref; { let num = 4; num_ref = # } // тут переменная num будет уничтожена, выйдя за пределы блока. // Однако num_ref ссылается на её адрес, // но пока все в порядке, наличие висящей ссылки не проблема // а вот попытка разыменовать такую ссылку - проблема, т.е. UB println!("say number {}", *num_ref); }
#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
Мутабельные ссылки и модель алиасинга
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); }
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 внезапным изменением значения, поскольку наше внимание приковано к именам переменных и подразумевает, что эти данные разные.
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; }
input
и output
указывают на разные значения, потому можно все разыменования заменить на обращение к копии данных в локальном кеше в регистре. Либеральные C и C++ такого позволить себе не могут: вдруг программист рассчитывает на мутабельный алиасинг. 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; } } }
let x = 0; let z; let y = &x; z = y;
'a: { let x: i32 = 0; 'b: { let z: &'b i32; 'c: { // Для &y используется 'b вместо 'c // поскольку эта ссылка передана // в переменную из scopa-а 'b let y: &'b i32 = &'b x; z = y; } } }
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; } }
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
является ссылкой на часть вектора или что такое вектор, и как он оперирует данными в куче. Компилятору достаточно того, что нарушено правило заимствования.
'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); } }
x
должен прожить время ‘b
, чтобы быть напечатанным в println!()
, что и отражено в выведенной сигнатуре функции Index::index
, которая в качестве входного аргумента дженерика принимает lifetime ‘b
. Ниже мы пытаемся заимствовать data
с меньшим lifetime, на что и ругается компилятор. Если просто убрать println!(“{}”, x)
, все будет работать корректно, поскольку висящий указатель/ссылка – не проблема. Проблема – разыменовывание такой ссылки. На самом деле это может быть проблемой, если мы пишем свой деструктор, но об этом поговорим в другой статье.Замалчивание Lifetime (Elision)
&'a T
– ссылка на переменную типа T
.&'a mut T
– мутабельная ссылка на переменную T
.T<'a>
– сигнатура типа T в случае, если поля структур или сигнатуры методов трейтов содержат ссылки.fn do_something(a: &str, b: &str)
у аргументов a
и b
будут разные lifetime.fn do_something(s: &’a str) -> (&’a str, &’a str)
в нашем случае вернет кортеж с двумя ссылками, но это может быть и структура с более чем одним ссылочным полем&self
или &mut self
, то все выходные замалчиваемые lifetime будут выведены равными lifetime ссылки на self
.
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> // явно
Вывод
- 1 views
- 0 Comment
Свежие комментарии