🏆 151 курс за 1 подписку: хватит выбирать — бери все и сразу!

Один клик — 151 возможность. Подпишись на OTUS сейчас!
Техномир мчится вперед, а вместе с ними растут и требования к специалистам. OTUS придумал крутую штуку — подписку на 151 курс по всем ключевым направлениям IT!
-
Почему подписка OTUS меняет правила игры:
- Доступ к 151 курсу от практикующих экспертов
- В 3 раза выгоднее, чем покупать каждый курс отдельно
- До 3 курсов одновременно без дополнительных затрат
- Свобода выбора направления — меняй треки когда угодно
Изучай новое, развивайся в своем темпе, меняй направления — подпишись на OTUS и прокачивай скилы по полной!
Реклама. ООО «Отус онлайн-образование», ОГРН 1177746618576. Erid 2VtzqupFnNL
Значит ли это, что вам придется прочитать сложные тексты на эту тему? Скажем, когда-нибудь – да. Пока эту задачу старается решить наша статья, которая во-первых написана по-русски, а во-вторых содержит некоторую интерпретацию официальной документации, призванную снизить ставшую мемом «Крутую кривую обучения» (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++, где блок служит для семантического ограничения доступа к переменным.
Возьмём простой пример:
fn main() {
let num_ref;
{
let num = 4;
num_ref = #
} // тут переменная num будет уничтожена, выйдя за пределы блока.
// Однако num_ref ссылается на её адрес,
// но пока все в порядке, наличие висящей ссылки не проблема
// а вот попытка разыменовать такую ссылку - проблема, т.е. UB
println!("say number {}", *num_ref);
}
При попытке собрать эту программу компилятор выдаст ошибку:

Попробуем смоделировать на 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
Получим следующий результат:

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