Для кого эта статья Для многих программистов, привыкших к мутабельным состояниям данных в объектно-ориентированой упаковке, 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); }
При попытке собрать эту программу компилятор выдаст ошибку
Попробуем смоделировать на 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
Получим следующий результат:
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; } }
Вопрос почему функция должна вернуть ссылку с 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 и не заставит отлаживать себя в самое неподходящее время. В следующий раз мы заглянем ещё глубже под капот и немножко приоткроем тайну, как компилятор делает магию владения. За пределами этой статьи остались такие важные темы, как автоматическое управление данными в куче и связывание их с переменными на стеке.