🛠 Владение и заимствование в Rust: детально о Lifetime для начинающих и более опытных
Владение и заимствование – ключевая концепция языка Rust и его неоспоримое преимущество. Необходимость освоить эту концепцию является насущной для любого разработчика, а ключом к ней станет Lifetime. Поговорим об этом детально.
Значит ли это, что вам придется прочитать сложные тексты на эту тему? Скажем, когда-нибудь – да. Пока эту задачу старается решить наша статья, которая во-первых написана по-русски, а во-вторых содержит некоторую интерпретацию официальной документации, призванную снизить ставшую мемом «Крутую кривую обучения» (Steep learning curve). Для понимания вам нужно хотя бы в общих чертах представлять себе, что такое куча и стек и зачем они нужны. В той или иной степени этот вопрос раскрывают материалы по разным языкам программирования. Также очень важно выучить буквально три пункта правил владения.
В чем проблема?
Это стандартное продающее язык объяснение. Посвященные могут его пропустить.
Ещё больше проблема усугубляется потребностью писать многопоточный код, который в нескольких экземплярах имеет прямой доступ к общей для всех потоков памяти. Тут важно понимать, что запросы на модификацию данных в памяти могут быть (с точки зрения процессора) не атомарными, а значит может потребоваться несколько инструкций для одной операции. Если же выполняются два потока одновременно, то инструкции процессора, скажем, перемешиваются, и получается бардак, называемый гонкой данных (Data Races), что в свою очередь является неопределенным поведением (Undefined Behaviour, далее UB). Отладка программы в поисках причины UB – чрезвычайно трудоемкий процесс.
Ссылки
В целом &ссылки/*указатели нужны когда данные: находятся в куче (отдельная тема), или просто ради того, чтобы избежать избыточное копирование данных в стеке.
В Rust есть ровно два типа ссылок (про сырые указатели не говорим):
- Ссылки на чтение (shared reference):
&lnk_name
– позволяет только читать данные. - Изменяемые (мутабельные) ссылки:
&mut lnk_name
– позволяет изменять данные.
Ссылки подчиняются двум правилам, называемым правилами заимствования:
- Ссылки не должны жить дольше чем данные, на которые они ссылаются (Rust Book описывает это так: «все ссылки должны быть действительными»).
- Мутабельная ссылка должна быть уникальна или, цитируя Rust Book: «в один момент времени может существовать либо одна изменяемая ссылочная переменная, либо любое количество неизменяемых ссылочных переменных». Забегая вперед: на мутабельных ссылках запрещен алиасинг. Англицизм тут нужен для описания конкретного эффекта внезапного изменения значения под ссылкой. Это справедливо не только для области видимости, но и для всей выполняемой ветки программы. Скажем больше, мутабельная ссылка должна быть уникальной и в нескольких потоках, что достигается с помощью Mutex.
Что такое Lifetime?
Вспомним первое правило ссылок: данные должны жить дольше чем ссылки на них, и именно эту гарантию дает язык Rust с помощью Lifetime.
Lifetime – именованная область программы, ссылки в которой будут действительными. Эти области могут быть очень сложными, поскольку они соотносятся с ветвлением при выполнении программы.
Вот прямо так. Не время – область кода. Для нас это абстрактное время, поскольку области кода выполняются последовательно. Lifetime – это как имя самой области, так и метка для ссылки на эту область. Запомним это.
Начнем с того, что время жизни есть не только в Rust. Обычно в C-подобных языках время жизни переменных ограничено функциями (эксперты по стандарту C/C++ поправьте, если что). И в Rust и в С/C++ можно ограничить область видимости переменных синтаксисом блоков “{...}”, однако в Rust блок гарантировано определяет как «долго» переменная проживет на стеке, в отличие от C/C++, где блок служит для семантического ограничения доступа к переменным.
Возьмём простой пример:
При попытке собрать эту программу компилятор выдаст ошибку:
Попробуем смоделировать на C что могло получиться, если бы Rust нас не защитил.
В C блоки кода работают немного иначе. Такая же программа на C будет работать корректно в силу того, что у C нет потребности при выходе из блока выкинуть данные из стека. Потому в программе на C для ограничения области видимости переменной воспользуемся функцией. Это гарантирует ликвидацию переменной в стеке и воспроизводит поведение Rust при выходе за пределы блока.
Скомпилируем и исполним:
Получим следующий результат:
Borrow-checker просто не позволяет нам написать подобную программу на Rust. GCC видит проблему, но все равно компилирует код. Наш пример слишком прост, чтобы он пропустил ошибку (clang программу с такой функцией и вовсе убережет от ошибки сегментирования), однако C и C++ никак не защищают от подобного с точки зрения семантики языка. В простых случаях мы можем положиться на компилятор, но нет языковых механик, которые уберегут нас от проблемы в принципе. На помощь придёт стандартная библиотека C++, хотя даже с ней можно отстрелить себе что угодно.
Мутабельные ссылки и модель алиасинга
Этот вопрос мы рассмотрим в контексте однопоточного исполнения без прерываний. Асинхронное программирование выходит за пределы темы.
Возьмем пример из Rust Book:
Видно что это прямое нарушение правил заимствования. Rust Book весьма лаконично описывает что такое мутабельный алиасинг фразой: «Пользователи неизменяемой ссылки не ожидают внезапного изменения значения, на которые она указывает!» Однако на этом примере совершенно не очевидно, почему это плохо.
Давайте разберем другой код, в котором это эффект в глаза не бросается, но покажет, почему мутабельная ссылка должна быть уникальной:
При логичном её использовании результат будет ожидаемым – в output
будет положено число 2:
Однако в функции compute()
есть один интересный эффект. Что если мы передадим в качестве обоих аргументов ссылки на один и тот же кусок памяти?
if
значение input
изменится и второй блок if
просто не сработает, поскольку input
тоже изменился и содержит значение менее 5. Это и было названо в Rust Book внезапным изменением значения, поскольку наше внимание приковано к именам переменных и подразумевает, что эти данные разные.Пока здесь сложно разглядеть что-то совсем разрушительное, более того, есть вероятность, что некоторые программы могут эксплуатировать такое поведение во благо (но это не точно). Для нас важен факт, что большинство языков с их либеральным подходом формально разрешают мутабельный алиасинг.
Теперь представьте, что таких блоков в функции много и есть много функций, которые рассчитывают на мутабельный алиасинг. Скорее всего вместе с внезапным изменением значений неизменяемых ссылок вы получите внезапный неожиданный результат.
Rust так делать не позволяет и пытается оптимизировать функцию:
- сохранить значения в регистрах процессора, подтвердив отсутствие обращений через указатель;
- убедиться, что память не была перезаписана перед её чтением;
- убедиться, что память не была прочитана перед записью;
- позволить переупорядочить чтение и запись, не боясь что значения зависят друг от друга.
Компилятор пожелает сделать код функции, который можно выразить так:
input
и output
указывают на разные значения, потому можно все разыменования заменить на обращение к копии данных в локальном кеше в регистре. Либеральные C и C++ такого позволить себе не могут: вдруг программист рассчитывает на мутабельный алиасинг.Lifetime как блок и как тип
Как было сказано выше, lifetime – это область кода, в которой живет переменная/данные и эти области компилятор должен разметить, чтобы применить ограничения и выявить проблемы времени жизни. В Rust в night-сборках даже есть специальный синтаксис, который позволяет применять lifetime-метки в блоках явным образом. Этот синтаксис поможет нам «рассахаривать» исходный код в представление, которое выводит компилятор в итоге.
Например, этот код:
можно выразить так:
Lifetime всегда указывается через апостроф. Сама метка ничего не говорит нам об относительных размерах lifetime (за исключением ‘static). Для нас имя времени жизни служит только признаком равенства или неравенства, остальное – забота компилятора. Однако вложенность блоков явно показывает, какой lifetime длиннее.
Как видно из этого примера, создание переменной порождает вложенный блок с более коротким lifetime. Порядок создания переменных в стеке важен для соблюдения обратного порядка их уничтожения. Потому lifetime для следующего кода будут выведены несколько иначе:
Передача ссылки за пределы scope заставит Rust вывести более длинные lifetime:
Функции и структуры могут содержать ссылки, и тогда их сигнатуры приобретут генеричный вид. Имя lifetime в таком случае является неотъемлемым входным параметром дженерика как и типовый параметр. Даже если мы не будем прописывать параметр(ы) lifetime явно, они в любом случае будут выведены компилятором сразу после появления в сигнатуре символа “&”.
Можно выразить так:
Вопрос почему функция должна вернуть ссылку с lifetime не меньшим чем входящий, в целом, должен быть уже очевидным (мы разберем его в деталях). Естественно, данную функцию будет правильно написать так:
String
в отличие от ссылки будет не заимствоваться, а перемещаться, по сути это умный указатель: его можно переместить без создания ссылки, а сама строчка будет лежать в куче.Попробуем «рассахарить» ещё один кусок кода, который отобразит проблему мутабельного алиасинга:
x
– ссылка на часть вектора. Так как вектор – это умный указатель, то при добавлении элемента хранилище в куче может быть перераспределено вне зависимости от нас и все ссылки на старые данные станут недействительны (ошибка сегментирования и/или UB). Однако компилятору не надо ничего знать про то, что x
является ссылкой на часть вектора или что такое вектор, и как он оперирует данными в куче. Компилятору достаточно того, что нарушено правило заимствования.Компилятор выведет следующий код из которого сразу видно проблему с lifetime:
Таким образом 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 и заставит вас сделать это явным образом
Давайте теперь посмотрим на примерах:
Вывод
Начав читать эту статью, вы хотели избавиться от проблем. На деле все прозаично: надо понять, как в действительности работает владение и заимствование в Rust, а также воспринимать ругающийся компилятор как великое благо. Да, он заставит вас переписать код, зато этот код не выстрелит вам в ногу, руку или в голову 31 декабря в 23:30 и не заставит отлаживать себя в самое неподходящее время. В следующий раз мы заглянем ещё глубже под капот и немножко приоткроем тайну, как компилятор делает магию владения. За пределами этой статьи остались такие важные темы, как автоматическое управление данными в куче и связывание их с переменными на стеке.