Привет, Хабр!

Сегодня я хочу поговорить о заимствованиях в Rust – теме, которая очевидно вводит многих в заблуждения но первых порах, но жизненно необходима для каждого, кто хочет писать на Rust. Мы с вами разберём, зачем Rust ввёл эту концепцию, как она работает под капотом, и какие нюансы следует знать, чтобы подружиться с borrow checker’ом, а не воевать с ним каждый раз при компиляции кода.

Почему вообще возникла необходимость в системе владения и заимствования? Дело в том, что Rust стремится к двум вещам одновременно: высокому уровню контроля над памятью, как в C/C++, и безопасности памяти без сборщика мусора. В языках вроде C/C++ программист сам управляет выделением и освобождением памяти, из-за чего легко допустить ошибки – использовать невалидный указатель, забыть освободить память или вызвать гонку данных в многопоточной среде. Rust решает эти проблемы на уровне компилятора, не полагаясь на garbage collector и runtime, а с помощью строгих правил владения (ownership) и заимствования (borrowing). Эта система, по замыслу, не даёт «прострелить себе ногу»: код, который в потенциально опасных языках скомпилировался бы с ошибкой, в Rust просто не пройдёт проверку компилятора. Однако плата за это – крутая кривая обучения: концепция владения/заимствования сперва кажется замороченной.

Первый проект на Rust заставил меня поседеть: компилятор упорно выдавал ошибки заимствования, и приходилось гуглить каждое сообщение. Borrow checker буквально высасывал весь сахар из крови моего мозга, пока я пытался понять, почему же оно не компилируется. Но со временем начинаешь понимать логик этих правил, и работа с Rust превращается из борьбы в сотрудничество. Давайте вместе разберём, что такое заимствование и как с ним работать, чтобы к концу статьи вы чувствовали себя увереннее и возможно даже полюбили borrow checker.

Кратко о владении и ссылках в Rust

Прежде чем нырять в заимствования, освежим в памяти базовые понятия. Владение (ownership) – это уникальное право на управление ресурсом. В Rust каждому значению в памяти есть ровно один владелец. Когда владелец выходит из области видимости, ресурс автоматически очищается вызовом drop. Это правило гарантирует освобождение памяти без утечек, но порождает следующую проблему: как передавать данные в функции или между частями кода, не теряя их владельца?

Тут работают ссылки (references) и заимствование. Ссылка в Rust это по сути указатель, с некоторыми важными отличиями. Ссылка указывает на данные, которыми владеет кто-то другой, и сама по себе не обладает этими данными. Важное отличие от сырых указателей в C: компилятор Rust гарантирует, что любая ссылка указывает на действительные данные правильного типа на протяжении всей своей жизни. Иначе говоря, Rust не позволит вам сослаться куда-то, что потом станет невалидным, система заимствования это отследит на этапе компиляции.

Синтаксически ссылка обозначается знаком &. Например, возьмём простую функцию, считающую длину строки:

fn calculate_length(s: &String) -> usize {
    s.len()
}
fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);
    println!("Длина строки \"{}\" – {}.", s1, len);
}

Передаём в calculate_length ссылку &s1 вместо самого объекта s1. Функция принимает &String, то есть ссылку на String, а не владение String. В итоге внутри calculate_length можно читать s, но не можем изменить или забрать эту строку себе. Важно, что после вызова calculate_length(s1) исходная строка s1 по-прежнему доступна в main, её владелец не поменялся. Мы одолжили строку на время вычисления длины и потом продолжили ею владеть в main. Таким образом, ссылка позволяет временно пользоваться данными без передачи владения. А действие создания ссылки принято называть заимствованием. По сути, владелец как бы даёт данные почитать кому-то, а потом этот кто-то их возвращает невредимыми.

Стоит подчеркнуть, что заимствование – это не копия и не перемещение данных. Если передавать значение по значению (т.е. перемещать владение), оригинальная переменная может стать недействительной. Если копировать, получится двойное хранилище. Заимствование же позволяет избежать и того, и другого: никаких копий, просто другой участок кода смотрит на те же данные по ссылке. В результате можно значительно повысить эффективность: передавать в функции большие структуры без копирования, работать с данными на месте и т.п. Rust поощряет такой подход, но и строго следит за тем, чтобы мы не натворили глупостей с этими ссылками.

Давайте теперь рассмотрим, какие бывают ссылки. В Rust есть два вида безопасных ссылок (сырые указатели const/mut – отдельная тема и здесь не рассматриваются):

  • Неизменяемая ссылка (&T) – дает доступ только на чтение к данным типа T. В коде её можно использовать, чтобы посмотреть или скопировать данные, но нельзя менять оригинал.

  • Изменяемая (мутабельная) ссылка (&mut T) – позволяет менять (мутировать) данные, на которые указывает. Чтобы создать изменяемую ссылку, сама переменная должна быть объявлена mut.

Создание изменяемой ссылки временно отдаёт эксклюзивное право на изменение данных тому, кто эту ссылку держит. И вот тут начинается самое интересное – правила заимствования, которые проверяет наш строгий товарищ borrow checker.

Правила заимствования: одна или много, но не всё сразу

Rust предъявляет две главные требования к тому, как мы заимствуем данные. Нарушить их не даст компилятор – он остановит сборку с ошибкой, если заметит конфликт. Эти правила обычно формулируют так:

  • Правило 1: Любая ссылка не должна жить дольше, чем живут данные, на которые она указывает. Другими словами, владелец данных не должен исчезнуть раньше заёмщика.

  • Правило 2: В любой момент времени может существовать либо одна изменяемая ссылка, либо любое количество неизменяемых к одному и тому же ресурсу. Но нельзя одновременно иметь и то, и другое. Про изменяемую ссылку говорят, что она должна быть уникальна, никаких «алиасов».

Эти два правила лежат в основе системы заимствований Rust. Рассмотрим, что из них следует.

Первое правило предотвращает появление «висячих» (dangling) ссылок. В том же C++ легко получить ситуацию, когда указатель указывает на память, которая уже освобождена, достаточно вернуть указатель на локальную переменную из функции. Rust же гарантирует на этапе компиляции, что подобного не случится: если вы попытаетесь вернуть ссылку на локальную переменную, компилятор откажется компилировать код. Пример:

fn dangle() -> &String {
    let s = String::from("test");
    &s  // пытаемся вернуть ссылку на локальную String
}

Этот код не скомпилируется. Функция dangle создает локальную строку s и возвращает ссылку на неё. Но как только функция завершится, s будет удалена (ведь её владелец, сама dangle, выходит из области видимости). Вернувшаяся ссылка оказалась бы указывать в никуда (память освобождена), классическая ошибка сегментирования. Borrow checker это предотвратит, выдав ошибку компиляции примерно следующего содержания:

error[E0106]: missing lifetime specifier
 --> src/main.rs:X:Y
  |
X | fn dangle() -> &String {
  |                ^ expected named lifetime parameter
...
error[E0515]: cannot return reference to local variable `s`
 ...

Сама формулировка ошибки подсказывает, в чём проблема: «функция возвращает заимствованное значение, но отсутствует значение, из которого оно заимствовано». Компилятор как бы говорит: “Ты возвращаешь ссылку, но не указал, на что она ссылается вне функции – ведь локальная s исчезнет”. Решение тут простое: вернуть сам String (передав владение наружу) или сделать s глобальным/static, чтобы он жил достаточно долго. Первый вариант предпочтительней. Исправим функцию:

fn no_dangle() -> String {
    let s = String::from("test");
    s  // возвращаем сам объект, передавая владение
}

Теперь всё ок, вернули строку по значению, владение переместилось к вызвавшему коду, и ни одна ссылка не повисла в воздухе.

Второе правило направлено на предотвращение гонок данных и нелогичных состояний. Оно запрещает одновременное изменение и чтение одного и того же ресурса из разных мест. Представьте если бы Rust позволял иметь две изменяемые ссылки на один объект сразу, то два разных куска кода могли бы менять один и тот же ресурс, не зная друг о друге. А если позволить одновременно менять данные через одну ссылку и читать через другую, то тот, кто читает, может получить не консистентные или неожиданно изменившиеся данные.

Одна изменяемая или множество неизменяемых звучит просто, но на практике часто трудно понять, где именно можно нарушить это правило. Посмотрим примеры ошибок, которые возникают при нарушении. Начнем с ситуации с двумя изменяемыми ссылками:

fn main() {
    let mut s = String::from("Rustaceans");
    let r1 = &mut s;
    let r2 = &mut s; // ошибка: вторая изменяемая ссылка на те же данные
    println!("{}, {}", r1, r2);
}

Компилятор выдаст ошибку, сообщающую что-то вроде “cannot borrow s as mutable more than once at a time”. Первая изменяемая ссылка r1 “заняла” s до своего последнего использования, а мы пытаемся снова изменить s через r2, нельзя. Если бы разрешить такое, два указателя одновременно указывали бы на одну строку для изменения, и непонятно, чей результат считать итоговым. Rust запрещает саму возможность подобных конфликтов.

Теперь пример смешения изменяемой и неизменяемых ссылок:

fn main() {
    let mut x = 42;
    let a = &x;
    let b = &x;
    let c = &mut x; // ошибка: уже есть &x, а мы хотим &mut
    println!("{} {} {}", a, b, c);
}

Здесь проблема в том, что пока существует хотя бы одна неизменяемая ссылка a или b, мы не можем получить изменяемую c на тот же x. И компилятор сообщит об ошибке типа “cannot borrow x as mutable because it is also borrowed as immutable”. И это логично: предположим, c бы удалось создать. Тогда пока a и b думают, что x = 42 (они ведь на него смотрят), кто-то через c взял бы и изменил x на, скажем, 99. И те, кто держал &x, остались бы с устаревшими данными, нарушение принципов константности. В Rust такого быть не должно.

Важно понять, что неизменяемых ссылок можно брать сколько угодно одновременно. Они друг другу не мешают, потому что только читают. Никто из них не может подложить что то остальным, внезапно поменяв данные. Поэтому код типа:

let r1 = &value;
let r2 = &value;
println!("{} and {}", r1, r2);

абсолютно нормален (оба r1 и r2 просто читают value). Но до тех пор, пока r1 или r2 используются, никакого &mut value быть не может.

Вы могли заметить в последнем примере: я сначала взял две &x, потом использовал их в println!, а после этого создал &mut x. Такой код компилируется успешно.

let mut x = 10;
let a = &x;
let b = &x;
println!("{} {}", a, b); // используем a и b
// a и b больше не используются после этой строки
let c = &mut x;          // теперь можно мутабельно заимствовать
*c += 5;
println!("{}", c);

Почему это сработало, ведь a и b объявлены в той же области видимости? Дело в том, что компилятор следит за временем использования ссылок. Ссылки a и b перестают быть нужными сразу после println!, и хотя формально их область видимости – весь блок main, borrow checker умный: он видит, что после печати мы к a и b уже не обращаемся. Поэтому он как бы досрочно закрывает их заимствование там, где они последний раз используются. После этого x опять можно занять изменяемо. Эта возможность результат введения в Rust понятия нелексических времен жизни (NLL), которое сделало анализ заимствований более гибким. Раньше, в старых версиях Rust, такой код бы не прошёл компиляцию без дополнительных блоков, а теперь компилятор сам разбирается, что пересечений нет. Если хочется явно ограничить время жизни ссылки, можно заключать код в дополнительные { }, создавая вложенный scope. Но зачастую, как видим, это излишне, компилятор достаточно умен.

На самом деле, смысл второго правила глубже, чем просто предотвращение конфликтов в однопоточных программах. Гонки данных исключаются на уровне типа. В многопоточной среде единственная изменяемая ссылка означает, что нельзя в двух потоках одновременно писать в одни данные без синхронизации, Rust просто не даст так поделить ссылку между потоками. Если же данные разделены на чтение (&T), Rust требует, чтобы тип T был безопасен для параллельного доступа (реализовал Sync). По дефолту обычные типы этим требованиям удовлетворяют, и несколько &T могут спокойно существовать в разных потоках. А вот изменяемую ссылку &mut T нельзя так просто передать в два потока: либо она одна и только в одном потоке, либо нужно обернуть ресурс в атомарную переменную или мьютекс. Например, чтобы разделить изменяемый доступ между потоками, мы используем Arc<Mutex<T>> счётчик ссылок с мьютексом, гарантирующий уникальность доступа по времени. Но это уже совсем другая история. Правила заимствования едины и в однопоточном, и в многопоточном коде компилятор Rust на страже, чтобы не было ни конфликтов, ни data race.

Жизнь ссылок: разберёмся с lifetime

Мы уже поняли, что ссылка не должна пережить объект, на который она ссылается. Но как компилятор отслеживает это формально? Он вводит понятие времени жизни (lifetime) каждой ссылки и каждого объекта. Время жизни – это отрезок программы, на котором данная переменная существует (грубо говоря, с момента объявления до конца блока). У каждой ссылки тоже есть время жизни, и Rust требует, чтобы время жизни ссылки не превышало время жизни объекта.

Часто время жизни называют ещё "сроком жизни" или "lifetime". Почти всегда lifetimes неявны: компилятор сам выводит их и всё проверяет. Но бывают ситуации, когда из кода не очевидно, какая переменная доживёт дольше, и тогда компилятор просит нас помочь – явно указать связи lifetimes в сигнатуре функции или типа.

Рассмотрим простой пример с областями видимости:

let r;
{
    let x = 5;
    r = &x;
} 
println!("{}", r);

Переменная x внутри внутреннего блока живёт меньше, чем переменная r снаружи. Мы пытаемся сохранить ссылку &x в r и использовать её вне блока, после уничтожения x. Это типичный случай «висячей ссылки», и компилятор нам сразу об этом скажет (ошибка x does not live long enough”, указывающая что x дропается раньше, чем последний использующий её ссылку). По сути, borrow checker сравнил время жизни 'b (для x) и 'a (для r), увидел, что 'a шире, и запретил такую привязку.

Чтобы управлять временем жизни ссылок в сложных случаях, Rust предлагает нам аннотации времени жизни. Они выглядят как штрих с именем, например &'a i32 – “ссылка с именованным временем жизни 'a на i32”. Эти аннотации не влияют на исполнение программы (не вводят никакого дополнительного кода или расходов), они нужны только компилятору для проверки. По сути, мы маркируем, какие ссылки должны жить не меньше (или не больше) каких других, и компилятор на основе этих пометок решает, корректен ли код.

В абсолютном большинстве случаев вам не придётся писать аннотации времени жизни вручную. Есть несколько базовых правил выведения lifetimes, покрывающих самые распространённые случаи. Например, если функция имеет одну входящую ссылку и возвращает ссылку, то компилятор автоматически считает, что возвращаемая ссылка связана по времени жизни с входящей. Вот простой случай:

fn identity(x: &str) -> &str {
    x
}

Эта функция просто возвращает то же самое строковое срез &str, которое получила. Компилятор понимает, что выходная ссылка ссылается на данные, пришедшие через параметр x, поэтому не требует никаких аннотаций, срабатывает правило выведения. А вот если функция принимает две ссылки и возвращает одну из них, компилятор уже не сможет однозначно вывести время жизни результата. Ему нужно явно сказать, как связаны времена жизни входов и выхода. Например:

fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() >= b.len() { a } else { b }
}

Здесь мы ввели параметр 'a в сигнатуру, он означает: “и a, и b, и возвращаемая ссылка живут не меньше некоторого времени 'a”. Иными словами, возвращаемая ссылка будет действительно столько же, сколько живёт тот из параметров, что передан (логично: она же ссылается на один из них). Если попробовать без <'a> – получите ошибку компиляции о «missing lifetime specifier», потому что Rust не знает, к чему привязать lifetime результата. Впрочем, как только вы укажете 'a для всех ссылок, становится ясно: что пришло, то и вышло, жизнь результатов ограничена жизнью аргументов.

Отдельно отмечу: аннотации времени жизни – это не какая то фича продления жизни. Они не могут заставить ссылку жить дольше, чем реально живёт объект. Они лишь добавляют в статический анализ информацию о взаимосвязях. Частая ошибка в том, чтобы увидеть сообщение “рассмотрите возможность использования 'static lifetime” и подумать: «О, сейчас поставлю 'static и проблема решена». 'static означает, что ссылка живёт весь срок работы программы (обычно таким свойством обладают строковые литералы, например, &'static str для константной строковой константы). Если ваши данные действительно глобальны, ок. Но пытаться присвоить локальной переменной 'static абсолютно неправильно. Компилятор и не даст так схитрить (если только вы сознательно не пожертвуете безопасностью через unsafe). Поэтому не надо решать проблемы с заимствованием путём разбрасывания 'static. Лучше разобраться, почему компилятор недоволен, и выразить отношения времени жизни корректно.

Ещё немного о “висячих” ссылках

Раз уж речь зашла о lifetimes, давайте чуть глубже посмотрим на dangling references (висячие ссылки), ведь вся система lifetimes придумана именно ради них. Мы уже видели пример с возвратом ссылки на локальный объект. Ещё один классический пример: сохранять во внешней переменной ссылку на временное значение. Например:

let bad;
{
    let temp = String::from("I am temp");
    bad = temp.as_str();
}
// здесь temp уничтожен, а bad теперь висячая ссылка
println!("{}", bad);

Метод temp.as_str() возвращает строковый срез &str, ссылающийся на данные внутри temp. Мы записали этот срез в bad за пределами блока, хотя temp уже умер. Итог – bad указывает на освобождённую память. Конечно, Rust не позволит даже скомпилировать эту бомбу замедленного действия. Решение – либо объявлять bad и temp в одной области, либо продлить жизнь temp, либо, опять же, не выносить ссылку за пределы жизни источника.

По сути, borrow checker ваш друг, предохраняющий от дурости. Сначала эти ошибки раздражают, но потом начинаешь понимать: каждая такая ошибка потенциальный баг, который не попал в вашу программу. Лучше уж изменить код по требованию компилятора, чем отлаживать код ночами.

Приёмы

Общая теория – это хорошо, но как применять всё это на практике? Разберём несколько ситуаций и приёмов, с которыми вы столкнётесь, работая с заимствованиями.

1. Делим сложные структуры на части. Часто ошибка заимствования возникает, когда мы пытаемся одновременно изменять и читать разные части одной структуры. Компилятор Rust консервативен: если вы взяли &mut на структуру, то на время заимствования вся структура считается занятой эксклюзивно. Даже если вы хотели поменять только одно поле, а другое поле просто читать, Rust не разберётся, он заблокирует весь объект. Решение – разбить операцию на два шага (чтобы мут и иммутабельные ссылки не пересекались во времени), либо использовать методы вроде [std::mem::take] или [Option] для временного изъятия части данных. Либо, если структура содержит независимые части, можно заимствовать каждую по отдельности: Rust позволяет брать две изменяемые ссылки на разные поля одной структуры, но сделать это надо явно. Например:

struct Point { x: i32, y: i32 }
fn main() {
    let mut p = Point { x: 1, y: 2 };
    let px = &mut p.x;
    let py = &mut p.y;
    *px = 5;
    *py = 10;
    println!("Point: ({}, {})", p.x, p.y);
}

Этот код компилируется, потому что мы берём отдельные &mut на p.x и p.y. Компилятор видит, что это разные поля (разные памяти участки) и допускает два изменяемых заимствования одновременно, так как они не пересекаются. А если бы мы написали let r1 = &mut p; let r2 = &mut p;, поймали бы ошибку. Так что иногда надо помочь компилятору понять вашу логику, разложив операции.

2. Возврат ссылок из функций. Представьте, что у нас есть структура, хранящая внутри какую-то коллекцию, и метод, возвращающий ссылку на элемент коллекции. Например, упрощённо:

struct Container { items: Vec<String> }
impl Container {
    fn get_first(&self) -> Option<&String> {
        self.items.first()
    }
}

Метод get_first возвращает ссылку с типом &String. Компилятор выведет для него lifetime, эквивалентный следующему: fn get_first<'a>(&'a self) -> Option<&'a String>. То есть ссылка на элемент живёт столько же, сколько живёт и весь контейнер (точнее, столько же, сколько ссылка &self переданная в метод). Это логично: пока у нас есть контейнер, действительны и ссылки на его элементы. Но если бы мы попытались вернуть ссылку на локальную строку, созданную внутри метода, как в примере с dangle(), ничего не выйдет, нужно вернуть владение или использовать другую стратегию.

3. Заимствование и Self в методах. Обратите внимание, что методы с &self и &mut self это тоже заимствования. Когда вы вызываете метод через точку, компилятор неявно передаёт ссылку. Например, container.len() где len определён как fn len(&self) -> usize – тут self на самом деле это &container. А у метода push для Vec<T> сигнатура fn push(&mut self, value: T), поэтому вызов my_vec.push(x) передаёт &mut my_vec. Понимание этого помогает, когда вы видите сообщение об ошибке вроде “cannot borrow foo as mutable because it is also borrowed as immutable”: возможно, одна из предыдущих строк вызвала метод с &self и не отпустила ещё эту ссылку. Иногда нужно реорганизовать код так, чтобы изменияющие методы (&mut self) не вызывались параллельно с читающими методами (&self) на одном объекте.

4. Внутренняя изменяемость. Бывают ситуации, когда по логике вещи можно менять данные, даже если у вас есть только неизменяемая ссылка на них. Стандартный пример – структурка, которая считает сколько раз к ней обратились (счётчик обращений). Её методы могут быть объявлены как принимающие &self (то есть не требующие уникального владения), но тем не менее внутри надо обновлять счётчик – мутабельность внутри. Как это возможно при строгих правилах? Здесь на помощь приходит паттерн внутренней изменяемости. Он основан на том, что правила заимствования можно проверить во время выполнения вместо компиляции и разрешить чуть более гибкие случаи. В стандартной библиотеке для этого есть оболочки вроде [Cell<T>] и [RefCell<T>]. Например, RefCell<T> позволяет запрашивать изменяемую ссылку на хранимые данные даже если само RefCell находится за неизменяемой ссылкой. Конечно, просто так такое не прошло бы, внутри RefCell скрыто много всего в виде unsafe кода, который ручками проверяет правила заимствования в runtime (если вы пытаетесь одновременно взять две &mut у одного RefCell – программа упадёт с panic). Зато вам, как пользователю, предоставляется безопасный API: метод borrow_mut() возвращает вам RefMut<T> – аналог &mut T, но проверяемый при каждом вызове. Прелесть RefCell в том, что он позволяет реализовать сценарии, которые компилятор не может просчитать статически, хотя они логически безопасны. Но злоупотреблять им не стоит: если проблему можно решить перестройкой кода или другими средствами, лучше обойтись без RefCell, потому что любые ошибки в работе с ним вы получите уже на этапе выполнения (паники), а не при сборке.

Пример использования RefCell:

use std::cell::RefCell;
struct Demo {
    value: RefCell<i32>
}
let demo = Demo { value: RefCell::new(0) };
// У нас `demo` неизменяемый, но внутри него RefCell позволяет мутировать значение:
*demo.value.borrow_mut() = 5;
println!("Значение: {}", demo.value.borrow());

Здесь Demo иммутабелен, но мы смогли изменить внутренний i32. Если бы вдруг нарушили правило (скажем, взяли два borrow_mut() одновременно), то получили бы panic. Но компилятор такого не пропустит безнаказанно, он заставит нас явно ограничить область переменных типа RefMut так, чтобы две не жили вместе. Так что всё равно какая-никакая защита есть.

RefCell работает только в однопоточных сценариях. Для многопоточных есть атомарные варианты (например, std::sync::Mutex тоже позволяет мутабельно изменять данные через неизменяемую ссылку, но уже с блокировкой потока, и проверяет всё на этапе компиляции с помощью типа Sync). Общая идея: если очень нужно обойти ограничения borrow checker’а, есть инструменты, но использовать их надо осознанно и умеренно. 99% кода прекрасно обходится обычными ссылками.

5. Клише: clone vs borrow. Начав писать на Rust, многие новички попадают в ловушку: «Ругается borrow checker, наверное, надо сделать .clone() и всё заработает». Действительно, .clone() скопирует данные, и можно не мучиться с заимствованием. Но это зачастую плохое решение, ведущие к неэффективности. Правильнее понять, почему нельзя просто так взять ссылку, и решить проблему без лишних копирований. Иногда достаточно перегруппировать код, как мы обсуждали, или выделить небольшую функцию. Иногда стоит изменить структуру данных, например, использовать Rc<T> (разделяемое владение), если одно и то же должно принадлежать разным частям программы. Clone – крайний случай, когда данные относительно малы или действительно нужны независимые копии. В остальных ситуациях лучше использовать ссылки. Так вы пишете более идиоматичный Rust-код.

Заключение

Тема обширная, и, признаюсь, мы лишь исчерпали верхушку айсберга. Но уже сейчас можно сделать несколько важных выводов:

  • Заимствование – ключевой механизм обеспечения памяти Rust без GC. Вместо автоматического сборщика мусора – строгий компилятор, не допускающий опасных ситуаций.

  • Правила заимствования просты: либо множество читателей, либо один писатель; и никто не живёт дольше владельца данных.

  • Borrow checker – ваш наставник, а не враг. Он указывает на потенциальные проблемы. Со временем начинаешь писать код так, чтобы ему сразу было понятно, и ошибок становится меньше.

  • Lifetimes (время жизни) – инструмент, который может понадобиться для выражения сложных отношений ссылок. Но часто можно на него даже не смотреть: компилятор сам справляется с выводом.

  • Не бойтесь рефакторить код под требования компилятора. Лучше сделать код понятнее и безопаснее, чем бороться с системой типов. Rust иногда заставляет перестраивать логику, и зачастую это приводит к более ясному и надежному решению.

  • Продвинутые техники (RefCell, Rc, unsafe) существуют для тех случаев, когда статические правила слишком строги. Но сначала убедитесь, что без них никак. Помните, что unsafe отключает проверки компилятора, ответственность полностью ложится на вас.

Если вы дочитали до этого места, поздравляю, теперь в вашей голове, надеюсь, немного прояснилась картина того, как работает заимствование в Rust. Дальше будет легче: вы начнёте интуитивно чувствовать, где поставить & или &mut, когда нужно добавить lifetime-параметр, а когда проще скопировать значение.

Что ж, на этом всё, спасибо за внимание. Пишите безопасный код и да пребудет с вами сила.

Комментарии (6)


  1. kotan-11
    18.10.2025 03:21

    Начну немного издалека - за много лет программирования я заметил, что у любой программы начиная с некоторого уровня сложности модель данных превращается в Document Object Model - нечто похожее на HTML DOM: с элементами, укладывающимися в дерево, с произвольными перекрестными ссылками между этими элементами и с неизменяемыми ресурсами, которые могут произвольно разделяться (шарится) между иерархиями объектов. Самый очевидный пример - строки.

    Эта структура данных присутствует всюду - в GUI, в каждой модели документа, в каждом сложном иерархическом котроллере, да и само приложение, объединяющее все перечисленное, тоже может быть представленно таким же DOM-ом.

    Я поставил себе задачу выяснить как современные языки программирования поддерживают эту модель. Какие современные (или классические) концепции обеспечивают безопасность, эффективность и удобство работы с DOM-образными структурами данных.
    Для этого я софрмулировал сравнительный тест (или бенчмарк, хотя некоторым нравится это слово) Card DOM. По ссылке и описание задачи и критерии оценки.

    Я уже написал этот тест на JS (и думаю это покывает все GC-языки, хотя я и могу ошибаться). Сейчас делаю то же самое на C++.

    И следующим на очереди по идее должен быть Раст, но с ним все как-то не ладится. Для топологически корректного копирования DOM-узлов требуется вести Map(original->copy). Поэтому узлы графа нужно хранить в виде какого-то универсального указателя и потом приводить к известному конуретному типу или трейту. Я не нашел как это безопасно сделать в Расте. Может быть существует какой-то способ? Может быть топологически верная копия делается каким-то другим идиоматичным способом? Я не знаю, я не специалист в Расте. Поэтому я обращаюсь к вам и вашим читателям. Не могли бы выпоказать на примере Card DOM, как Раст справляется с DOM-подобными структурами данных. Это была бы прекрасная демонстрация языка, которая бы одновременно сыграла роль и рекламы и туториала.


    1. codecity
      18.10.2025 03:21

      у любой программы начиная с некоторого уровня сложности модель данных превращается в Document Object Model - нечто похожее на HTML DOM

      Это потому что вы работаете с определенным типом программ - Enterprise Application Software (EAS). Соглашусь что такого софта в реальной жизни (за что платят деньги - что приносит деньги) - основная масса. Как бы самое главное - это бизнес-процессы, это наша жизнь, как бы всем нужно кушать, одеваться, ездить и т.д. - это это все обеспечивается теми самыми бизнес-процессами и соответствующим софтом.

      Однако же есть и другие типы приложений, хотя многие за всю жизнь могут с ними не столкнуться ни разу - системные приложения. Так вот Rust заточен больше для системы, так по этому писать EAS вряд ли будет хорошей идеей.


      1. kotan-11
        18.10.2025 03:21

        Я пишу драйвер устройства для SoC. В нём используется древовидная структура функций, хранящая конфигурационные параметры. Узлы этого дерева могут ссылаться друг на друга через перекрестные ссылки. Запросы на изменение параметров, а также операции чтения и записи в блочные устройства организованы в очереди; они группируются в бакеты, где возможна перестановка операций при сохранении их взаимозависимостей. Бакеты и батчи также образуют иерархическую структуру. Взаимозависимости между элементами представляют собой перекрестные ссылки. Многочисленные неизменяемые сущности - идентификаторы запросов и транзакций, предопределённые типы операций - являются общими ресурсами, доступными разным частям системы. Вся эта архитектура естественным образом укладывается в DOM-подобную структуру данных.


    1. morett1m Автор
      18.10.2025 03:21

      это вправду прям большая проблема раста, c это структурой всё не так очевидно, но конечно решаемо

      нельзя просто так взять и иметь несколько изменяемых ссылок на один объект, язык этого не позволяет из за правл владения

      самый адекватный способ - комбо Rc и RefCell, узлы хранятся как Rc<RefCell<Node>>, а перекрест ссылки как Weak<RefCell<Node>>. при копировании создаёшь временную мапу, где ключ указатель на старый узел, а значение ссылка на новый, далее встречаешь узел, проверяешь, не копировал ли уже его, и либо используешь существующую копию, либо просто создаем новую

      еще можно вместо ссылок использовать индексы. создаешь Vec<Node>, а все связи между узлами выражаешь через обычные индексы этого вектора


  1. SteveJacobs
    18.10.2025 03:21

    Отличный матеиал.

    Я лично не пишу на Rust, пока не пишу. Но, любопытство к нему в последнее время зашкаливает. Всё-таки я пишу на одном диалекте Rust, недавно появившийся язык шейдеров WGSL. Другие шейдеры пишутся на диалектах С, как GLSL HSLS. Я не понимал за чем надо было всё усложнить, ведь до сих пор тут интуитивный понятный язык GLSL, и тут сразу WGSL который переворачивает всё с ног на голову. И тут уже и интерес не к некоему диалекту Rust а у самому Rust. Всё что я читаю о Rust заставляет переосмысливать то что я пишу на C++. В C++ можно придерживаться дисциплины Rust, но если ошибёшься, компилятор это свободно сжует. Синтаксис Rust кажется несколько неудобным, но я готов с этим мириться.


    1. morett1m Автор
      18.10.2025 03:21

      попробуете сам Rust, многое встанет на свои места. с++ после него уже не тот, так что добро пожаловать ;)