Владение и заимствование: модель памяти без сборщика мусора
В предыдущей статье мы разобрали типы, выражения и функции — но обходили стороной вопрос, который в Rust определяет вообще всё: кто и когда освобождает память под значением.
Это не вопрос синтаксиса. Это вопрос модели исполнения, и от ответа на него зависит, как вы будете проектировать структуры данных, писать сигнатуры функций и разбивать программу на модули. Пока эта модель не встала на место, Rust ощущается как язык, который «зачем-то мешает»; после — как язык, который заранее задаёт вопросы, на которые в C вы отвечали бы в три часа ночи под падающим продом.
Задача: три ответа индустрии на один вопрос
Возьмём простейшую ситуацию. Функция создала буфер, вернула на него указатель, вызывающий его использовал. Кто вызовет free?
Ответ первый — «программист» (C, старый C++). Быстро, предсказуемо, ноль накладных расходов в рантайме. Цена — целый класс ошибок: use-after-free, double free, утечки, висячие указатели, гонки данных. Их особенность в том, что они нелокальны: ошибка в модуле A проявляется падением в модуле B через сорок минут работы. Подробный разбор этих граблей — в статье Динамическая память и Неопределённое поведение трека системного программирования.
Масштаб проблемы известен по цифрам, а не по ощущениям: Microsoft Security Response Center насчитал, что ~70% CVE в продуктах Microsoft — нарушения безопасности памяти; та же доля у Chromium. Google, переведя часть Android на Rust, получил ноль уязвимостей памяти в новом коде.
Ответ второй — «рантайм» (Java, Go, C#, Python, JS). Сборщик мусора находит недостижимые объекты и освобождает их сам. Класс ошибок исчезает почти полностью. Цена — рантайм, который надо тащить с собой; паузы или фоновая работа GC; потеря контроля над моментом освобождения; и — важное — GC управляет только памятью. Файловые дескрипторы, сокеты, блокировки мьютексов он не закрывает, поэтому в языках с GC заводят try-with-resources, defer, using, finally. Как это устроено изнутри — см. JVM и память.
Ответ третий — «система типов» (Rust). Компилятор статически доказывает, кто владелец, и сам вставляет освобождение в точке, где владелец умирает. Ошибок нет, рантайма нет, паузы нет. Цена — ограничения на то, какие программы вы вообще имеете право написать, и время, потраченное на то, чтобы этим ограничениям научиться.
Ключевая мысль, которую стоит удержать: владение — это не «менеджер памяти внутри Rust». Это статическая дисциплина, которая позволяет компилятору знать точку освобождения на этапе компиляции. Всё остальное — следствия.
Модель: значение, владелец, область
Три правила, из которых выводится вся глава.
- У каждого значения ровно один владелец — переменная, поле структуры, элемент коллекции.
- Владелец в каждый момент один. Присваивание другой переменной, передача в функцию, возврат из функции, помещение в коллекцию — всё это перемещает владение (move).
- Когда владелец выходит из области видимости, значение уничтожается — вызывается
drop, и связанные ресурсы освобождаются.
Формально это аффинные типы: значение можно использовать не более одного раза. Компилятор отслеживает по каждому пути исполнения, где значение ещё живо, а где уже отдано.
Обратите внимание на переход Перемещена --> Владеет. Move не «портит» переменную навсегда: если присвоить ей новое значение, она снова живой владелец. Компилятор рассуждает не о переменных, а о местах (places) в каждой точке потока управления.
Move, Copy и clone: что физически происходит с байтами
Самое частое заблуждение новичка — считать move дорогой операцией. Это не так.
fn main() {
// Copy: i32 целиком лежит в переменной, дубликат безвреден
let a: i32 = 5;
let b = a;
println!("a = {a}, b = {b}"); // a = 5, b = 5 — оба валидны
// Move: String — это ptr + len + cap (24 байта на 64 бит) плюс буфер в куче
let s1 = String::from("Rust");
let s2 = s1; // 24 байта скопированы, буфер не тронут
// println!("{s1}"); // ОШИБКА: владение отдано s2
println!("{s2}"); // Rust
// Clone: явное второе выделение в куче
let s3 = s2.clone();
println!("{s2} и {s3}"); // Rust и Rust — два независимых буфера
}
Три вывода, которые стоит запомнить дословно:
- Move и Copy на уровне машинного кода — одно и то же: побитовое копирование «шапки». Разница целиком статическая — после move компилятор запрещает читать источник. В оптимизированной сборке move обычно вообще исчезает.
Copy— маркерный трейт для типов, у которых побитовый дубликат корректен: целые,f64,bool,char,&T, кортежи и массивы из таких. Тип сDropне может бытьCopy— иначе освобождение случилось бы дважды.- Неявных глубоких копий в Rust нет. Хотите второй буфер — пишете
.clone(). Это принципиальная позиция языка: дорогая операция обязана быть видна в коде. Сравните с C++, где копирующий конструктор может сработать незаметно.
Ошибка компилятора, которую вы увидите первой
fn main() {
let s1 = String::from("Rust");
let s2 = s1;
println!("{s1}");
}
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:4:15
|
2 | let s1 = String::from("Rust");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let s2 = s1;
| -- value moved here
4 | println!("{s1}");
| ^^^^ value borrowed here after move
|
help: consider cloning the value if the performance cost is acceptable
|
3 | let s2 = s1.clone();
| ++++++++
Как это читать. Сообщение компилятора Rust — это не «что-то не так», а связный рассказ из трёх частей:
^^^^— место, где вы нарушили правило (primary span);----— места, которые объясняют почему: где значение создано, где перемещено, где ссылка используется позже;note/help— причина в терминах модели и конкретное предложение правки, часто с готовым diff (++++).
Умение читать эти сообщения — буквально половина обучения Rust. Не бросайтесь править код по первому впечатлению: сначала прочитайте все вторичные метки, особенно ... later used here — именно она объясняет, почему компилятор считает регион заимствования более длинным, чем вам казалось. Полное объяснение любого кода доступно локально: rustc --explain E0382.
Заимствование: доступ без передачи владения
Если бы каждая функция забирала владение, программировать было бы невозможно: пришлось бы возвращать значения обратно кортежами. Поэтому есть ссылки — доступ к значению без владения им.
// Забирает владение: после вызова строка недоступна вызывающему
fn eat(s: String) -> usize { s.len() }
// Заимствует: вызывающий остаётся владельцем
fn look(s: &str) -> usize { s.len() }
// Заимствует исключительно: может менять
fn shout(s: &mut String) { s.push_str("!!!"); }
fn main() {
let mut msg = String::from("привет");
println!("{}", look(&msg)); // 12 (байт, не символов!)
shout(&mut msg);
println!("{msg}"); // привет!!!
println!("{}", eat(msg)); // владение ушло
// println!("{msg}"); // ОШИБКА: value borrowed here after move
}
Правило заимствования — одно, и оно сформулировано в терминах алиасинга:
В любой момент времени для конкретного значения существует либо произвольное число общих ссылок
&T, либо ровно одна исключительная&mut T— но не то и другое одновременно.
Часто это называют «один писатель или много читателей», по аналогии с RWLock. Аналогия рабочая, но неточная, и точность здесь важна. Правильные названия — shared reference и exclusive reference, а сам принцип называется aliasing XOR mutability: либо у данных несколько путей доступа, либо кто-то их меняет.
Почему именно так: что было бы иначе
Правило существует не «чтобы было строго». Оно запрещает конкретный сценарий: изменение через один путь доступа делает недействительным другой.
fn main() {
let mut v = vec![10, 20, 30];
let first = &v[0]; // общая ссылка внутрь буфера
v.push(40); // может потребовать реаллокации → буфер переехал
println!("{first}"); // читали бы освобождённую память
}
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
--> src/main.rs:4:5
|
3 | let first = &v[0];
| - immutable borrow occurs here
4 | v.push(40);
| ^^^^^^^^^^ mutable borrow occurs here
5 | println!("{first}");
| ------- immutable borrow later used here
В C++ ровно тот же код компилируется и «обычно работает» — до дня, когда вектор перешагнёт вместимость. Здесь это ошибка компиляции.
Тем же правилом ловятся ещё три вещи, о которых редко думают:
Инвалидация итератора. for x in &v { v.push(...) } — та же ошибка, тот же механизм. В Java это ConcurrentModificationException в рантайме, в C++ — UB, в Rust — компиляция не проходит.
Смена варианта перечисления из-под ссылки на его данные. Если бы можно было держать &String на полезную нагрузку варианта и одновременно перезаписать enum другим вариантом, ссылка стала бы ссылкой на данные другого типа. Классический источник эксплойтов в языках с небезопасными union.
Гонки данных. Гонка — это ровно алиасинг плюс изменение плюс параллелизм. Убрав первые два одновременно, Rust убирает и третье: Send/Sync в конкурентности достраивают ту же самую модель до многопоточности, не изобретая новую.
Бонус, о котором знают немногие. Из &mut T следует, что других путей к данным нет, — и rustc помечает такие аргументы атрибутом LLVM noalias. В C это restrict, который почти никто не расставляет. Оптимизатор получает информацию, которой у него в C обычно нет: см. Профилирование и оптимизация.
Ссылки не живут до конца блока: NLL
До Rust 2018 заимствование жило лексически — от взятия ссылки до закрывающей скобки блока. Это раздражало. Сейчас работает NLL (Non-Lexical Lifetimes, RFC 2094): регион заимствования заканчивается на последнем использовании ссылки по графу потока управления.
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
println!("первый: {first}"); // последнее использование first — здесь
v.push(4); // ОК: заимствование уже закончилось
println!("{v:?}"); // [1, 2, 3, 4]
}
По оси — номера строк. a и b пересекаются — это законно, обе общие. m не пересекается ни с одной из них — тоже законно. Если бы a использовалась на строке 7, диаграмма бы «наехала», и компилятор выдал бы E0502. Ментальная модель проста: рисуйте отрезки и смотрите на пересечения.
Чего NLL всё ещё не умеет — условного возврата заимствования. Канонический пример:
use std::collections::HashMap;
// Не компилируется даже при NLL: заимствование в ветке if продлевается на всю функцию
fn get_or_insert(map: &mut HashMap<String, Vec<u8>>, key: &str) -> &Vec<u8> {
if let Some(v) = map.get(key) {
return v;
}
map.insert(key.to_string(), Vec::new()); // E0502
map.get(key).unwrap()
}
Это NLL problem case #3, над которым работает следующее поколение анализатора — Polonius. Практический обход сегодня — идиоматичный entry API, который и без того эффективнее (один поиск вместо трёх):
fn get_or_insert<'a>(map: &'a mut HashMap<String, Vec<u8>>, key: &str) -> &'a Vec<u8> {
map.entry(key.to_string()).or_default()
}
Срезы: заимствование части
&str и &[T] — это «толстые указатели»: адрес начала плюс длина. Они не владеют данными, а описывают окно в чужом буфере, и потому подчиняются тем же правилам.
/// Возвращает первое слово. Ничего не копирует и не выделяет.
fn first_word(s: &str) -> &str {
match s.find(' ') {
Some(i) => &s[..i],
None => s,
}
}
fn main() {
let text = String::from("владение и заимствование");
let w = first_word(&text); // &String → &str: автоматическое приведение (deref coercion)
println!("{w}"); // владение
// text.clear(); // ОШИБКА E0502: w ещё жива
println!("{w}");
}
Отсюда практическое правило API: в параметрах пишите &str, а не &String; &[T], а не &Vec<T>. Первое принимает и то и другое (благодаря deref coercion), второе — только String/Vec. Обратно: возвращайте String/Vec, если функция создаёт данные. Про типы и приведения подробнее — в Типы и трейты.
Обратите внимание, какой баг здесь предотвращён: в Python или Java индекс i, сохранённый рядом со строкой, живёт своей жизнью — строку поменяли, индекс протух, и вы узнаете об этом в проде. Тут срез и буфер связаны системой типов.
Drop и RAII: владение — не только про память
drop вызывается автоматически, и это распространяется на любой ресурс, а не только на кучу.
struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) {
println!("drop {}", self.0);
}
}
fn main() {
let _a = Guard("a");
let _b = Guard("b");
{
let _inner = Guard("inner");
println!("во вложенном блоке");
} // здесь drop inner
println!("конец main");
}
во вложенном блоке
drop inner
конец main
drop b
drop a
Правила порядка (Reference: Destructors):
- переменные в блоке уничтожаются в обратном порядке объявления (стековая дисциплина);
- поля структуры и элементы кортежа — в порядке объявления;
- элементы
Vec— от начала к концу.
Порядок важен чаще, чем кажется: если в структуре лежат «соединение» и «пул», от порядка полей зависит, что закроется первым.
Прямых аналогов defer (Go), try-with-resources (Java) или using (C#) в Rust нет — потому что они не нужны. Файл закрывается, когда умирает File; мьютекс отпускается, когда умирает MutexGuard; транзакция откатывается, когда умирает Transaction, если её не закоммитили. Это тот же RAII, что в C++, но с гарантией: тип с Drop невозможно скопировать «мимо» деструктора.
Пара нюансов, на которых спотыкаются:
let s = String::from("x");
drop(s); // ОК: drop — обычная функция fn drop<T>(_: T) {}, забирающая владение
// s.drop(); // error[E0040]: explicit use of destructor method
Утечка памяти в Rust безопасна. Это сознательное архитектурное решение (по итогам «leakpocalypse» перед 1.0): язык гарантирует отсутствие неопределённого поведения, а не отсутствие утечек. std::mem::forget — безопасная функция; Box::leak — тоже; циклы Rc текут молча. Если ваша программа съедает память — компилятор не виноват и не поможет, идите в Память и аллокации.
Когда одного владельца мало: умные указатели
Дерево владения — мощная, но жёсткая структура. Когда данные не ложатся в дерево, используют типы, которые расширяют модель, не ломая её.
use std::rc::Rc;
use std::cell::RefCell;
fn main() {
// Разделяемое владение: буфер жив, пока жив хоть один Rc
let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
let clone_a = Rc::clone(&shared); // клонируется указатель, не данные: +1 к счётчику
let clone_b = Rc::clone(&shared);
clone_a.borrow_mut().push(4); // изменяемость перенесена в рантайм
println!("{:?}", clone_b.borrow()); // [1, 2, 3, 4]
println!("владельцев: {}", Rc::strong_count(&shared)); // владельцев: 3
}
RefCell переносит проверку правила «один писатель или много читателей» из компиляции в рантайм. Нарушили — паника, а не UB:
thread 'main' panicked at src/main.rs:9:24:
already mutably borrowed: BorrowError
Это честный размен: гибкость в обмен на проверку в рантайме и риск паники. Единственный легальный фундамент любой внутренней изменяемости — UnsafeCell; всё остальное (Cell, RefCell, Mutex, RwLock, атомики) построено на нём.
Практическая рекомендация. Rc<RefCell<T>> — рабочий инструмент, но если он появляется в коде часто, это почти всегда сигнал: модель данных не соответствует дереву владения. Прежде чем расставлять Rc<RefCell<_>> по всей программе, спросите — не проще ли хранить данные в одном месте (арене, Vec, HashMap), а связи выражать индексами или идентификаторами. Об этом ниже.
Циклы текут. Два Rc, ссылающиеся друг на друга, никогда не обнулят счётчики. Лекарство — Weak для «обратных» ссылок: родитель владеет детьми через Rc, дети смотрят на родителя через Weak.
Типичные грабли
Это самый практичный раздел главы. Почти всё, обо что бьются новички, укладывается в восемь сюжетов.
1. for забирает владение коллекцией.
let names = vec![String::from("Аня"), String::from("Боря")];
for n in names { println!("{n}"); } // IntoIterator по значению — вектор съеден
// println!("{names:?}"); // E0382: borrow of moved value
Лечится выбором формы итерации: &names (то же, что .iter()) даёт &String; &mut names даёт &mut String; names (то же, что .into_iter()) забирает владение. См. Коллекции и итераторы.
2. E0507: нельзя вынуть значение из-под ссылки.
let names = vec![String::from("Аня")];
let s = names[0]; // error[E0507]: cannot move out of index of `Vec<String>`
Индексация даёт доступ к месту внутри вектора, а не владение им — иначе в векторе осталась бы дыра. Варианты: &names[0] (заимствовать), names[0].clone() (копировать), names.remove(0) / names.swap_remove(0) (вынуть и починить вектор), names.into_iter().next() (съесть весь вектор), std::mem::take(&mut names[0]) (подменить на Default).
3. Метод заимствует весь self, хотя нужен один его кусок. Это грабля номер один при работе со структурами.
struct Server { queue: Vec<String>, log: Vec<String> }
impl Server {
// Компилируется: компилятор видит, что self.queue и self.log — разные места
fn run_ok(&mut self) {
for job in &self.queue {
self.log.push(format!("выполняю {job}"));
}
}
fn jobs(&self) -> &[String] { &self.queue }
// НЕ компилируется: jobs(&self) заимствует self целиком
fn run_bad(&mut self) {
for job in self.jobs() {
self.log.push(format!("выполняю {job}")); // E0502
}
}
// Починка: деструктурируем self и получаем независимые ссылки на поля
fn run_fixed(&mut self) {
let Self { queue, log } = self;
for job in queue.iter() {
log.push(format!("выполняю {job}"));
}
}
}
Компилятор умеет разделять заимствования полей (split borrows), но только когда видит поля напрямую. Через границу метода информация теряется: сигнатура &self говорит «весь объект». Отсюда три рабочих приёма: обращаться к полям напрямую, деструктурировать self, либо разбить структуру на две — если два поля постоянно нужны независимо, вероятно, это две сущности.
4. Временные значения умирают в конце инструкции (E0716).
struct Config { name: String }
fn read_config() -> Config { Config { name: " prod ".into() } }
fn main() {
let name = read_config().name.trim(); // временный Config умрёт здесь же
println!("{name}");
}
error[E0716]: temporary value dropped while borrowed
--> src/main.rs:5:16
|
5 | let name = read_config().name.trim();
| ^^^^^^^^^^^^^ - temporary value is freed at the end of this statement
| |
| creates a temporary value which is freed while still in use
6 | println!("{name}");
| ------ borrow later used here
|
help: consider using a `let` binding to create a longer lived value
Починка механическая: разложить на два let. Обратная сторона того же правила — временное значение, которое живёт дольше, чем вы думали:
use std::sync::Mutex;
fn main() {
let data = Mutex::new(vec![1, 2, 3]);
// ЛОВУШКА: временный MutexGuard живёт до конца ВСЕГО match
match data.lock().unwrap().len() {
0 => println!("пусто"),
n => data.lock().unwrap().push(n as i32), // вечное ожидание: std Mutex не реентрантный
}
}
Правильно — освободить guard до match:
let n = data.lock().unwrap().len(); // guard умирает в конце этой инструкции
match n { /* ... */ }
Похожая история была с if let в редакциях до 2024; в редакции 2024 её починили — временные значения условия теперь уничтожаются до ветки else. Для match поведение осталось прежним, и это надо помнить.
5. Самоссылающиеся структуры не выражаются. Структура, поле которой ссылается на другое поле той же структуры, в безопасном Rust невыразима: при перемещении структуры ссылка стала бы висячей. Обходы: хранить индексы вместо ссылок; хранить владеющие значения и вычислять срезы на лету; в крайнем случае — Pin и unsafe (см. unsafe и FFI; эта же проблема — корень сложностей с !Unpin в асинхронном Rust).
6. Графы и двусвязные списки — отдельная боль. Классическое struct Node { next: Option<Box<Node>>, prev: ??? } не работает: prev не может владеть тем, кто владеет им. Индустриальный ответ — арена: узлы лежат в Vec, а рёбра — это usize-индексы или типизированные ключи (slotmap, petgraph, id-arena).
struct Graph {
nodes: Vec<String>,
edges: Vec<(usize, usize)>, // индексы вместо ссылок: владение остаётся у nodes
}
Бонусом это ещё и быстрее из-за локальности данных (Кэш и локальность). Канонический учебник на эту тему — «Learning Rust With Entirely Too Many Linked Lists»: шесть реализаций одного списка, каждая упирается в свою грань модели. Сравните с обычной реализацией из Связных списков — разница поучительна.
7. Забыли mut.
error[E0596]: cannot borrow `v` as mutable, as it is not declared as mutable
|
2 | let v = vec![1, 2, 3];
| - help: consider changing this to be mutable: `mut v`
3 | v.push(4);
| ^ cannot borrow as mutable
Мелочь, но показательная: изменяемость в Rust — свойство привязки, а не типа. let v и let mut v имеют один тип Vec<i32>.
8. clone() как обезболивающее. Отдельно скажу об этом честно, потому что советы в интернете противоречивы.
Клонировать, чтобы «сделать, чтобы собралось», — нормальная стратегия для первых недель. Ставьте .clone(), доводите программу до работающего состояния, потом возвращайтесь и убирайте. Что важно понимать:
Rc::clone/Arc::clone— дёшево (инкремент счётчика), это не глубокая копия. Пишите именноRc::clone(&x), а неx.clone(), чтобы читателю было видно, что копируется указатель.String::clone/Vec::clone—malloc+memcpy. В холодном пути (разбор конфига при старте) это не имеет значения. В цикле, обрабатывающем миллион запросов, — имеет.- Клон, который пришлось поставить в горячем месте, — сигнал ошибки проектирования, а не «налог языка». Обычно правильный ответ: передавать
&str/&[T], а неString/Vec; или разделить владение черезArc; или использоватьCow.
Владение в замыканиях и потоках — короткая заметка
Замыкание захватывает переменные минимально необходимым способом: по &, по &mut или по значению — компилятор выводит это сам. Ключевое слово move заставляет захватить владением.
use std::thread;
fn main() {
let data = vec![1, 2, 3];
// move обязателен: поток может пережить текущую функцию,
// поэтому заимствование локальной переменной было бы висячим
let h = thread::spawn(move || println!("{data:?}"));
h.join().unwrap();
}
Это тот же самый механизм, что и везде, — но именно он делает многопоточность в Rust «бесстрашной». Полный разбор — в Бесстрашной конкурентности.
Цена модели: честно
Кривая обучения. Первые две-шесть недель уходят на «драку с borrow checker». Это нормально и проходит. Но проходит не потому, что вы выучите правила, а потому, что перестанете писать код, который эти правила нарушают: модель владения — это дисциплина проектирования данных, и она меняет то, как вы думаете о программе. Побочный эффект известен: люди, вернувшись после Rust в C++ или Python, начинают видеть в своём старом коде проблемы алиасинга, которых раньше не замечали.
Ограничения выразительности реальны. Произвольные графы, наблюдатели с обратными ссылками, GUI-деревья с родителями, кэши со ссылками внутрь себя — всё это в Rust пишется иначе и обычно многословнее. Это не «Rust плохой», это цена статической проверки: любой достаточно мощный анализатор отвергает часть корректных программ.
Компиляция медленная — но не из-за borrow checker. Проверка заимствований в MIR обычно занимает единицы процентов времени сборки. Основное время съедают мономорфизация дженериков и LLVM. Измеряйте, а не гадайте: cargo build --timings, cargo-llvm-lines, профили сборки — см. Установка и инструментарий.
Где Rust избыточен. Скрипт на день, прототип гипотезы, аналитическая тетрадка, CRUD, где 95% времени — ожидание базы, эксперименты в ML. Там вы заплатите временем разработки и компиляции за гарантии, которые не нужны: цена ошибки низкая, а производительность упирается в I/O.
Где выигрывает. Парсеры и кодеки; сетевые прокси и балансировщики; базы данных и движки хранения; встраиваемые системы без ОС; WASM; библиотеки, вызываемые из Python, Node или Ruby; и вообще любой код, живущий годами, где стоимость инцидента высока. Обзор ниши — Современные альтернативы C.
Связь с соседними треками
Модель владения станет намного понятнее, если посмотреть на неё «снизу».
- Что на самом деле происходит с кучей — Указатели и память и Динамическая память. Именно ошибки оттуда Rust и делает невыразимыми.
- Почему освобождение памяти вообще нетривиально — Управление памятью в ОС: виртуальная память, страницы,
brk/mmap, работа аллокатора. - Что даёт отсутствие GC на практике — Память и аллокации и Кэш и локальность: детерминированное освобождение и плотная раскладка данных — половина производительности системного кода.
А теоретический фундамент под всем этим — не «интуиция авторов». Модель формально верифицирована: RustBelt (Jung et al., POPL 2018) доказывает безопасность типовой системы и корректность ключевых unsafe-абстракций стандартной библиотеки; Stacked Borrows и его развитие Tree Borrows задают операционную семантику алиасинга, по которой работает интерпретатор Miri.
Мини-итог
- Владение отвечает на вопрос «кто освободит память» на этапе компиляции: один владелец, одна точка
drop, ноль рантайма. - Move — это memcpy плюс статический запрет на использование источника. Дорого не перемещение, а
clone, и он всегда виден в коде. - Правило заимствования — не «один писатель, много читателей», а aliasing XOR mutability: оно запрещает ситуацию, где изменение через один путь ломает другой. Отсюда же следует отсутствие гонок данных и атрибут
noaliasдля оптимизатора. - NLL: ссылка живёт до последнего использования, а не до конца блока. Рисуйте отрезки, смотрите пересечения.
dropработает для любых ресурсов, не только для памяти, — поэтому в Rust не нужныdeferиtry-with-resources.- Утечка памяти безопасна. Язык гарантирует отсутствие UB, а не отсутствие утечек: циклы
Rcтекут,mem::forgetразрешён. - Когда дерево владения не подходит:
Box(владение в куче),Rc/Arc(разделяемое владение),RefCell/Mutex(проверка в рантайме),Weak(разрыв циклов), арена с индексами (графы). - Сообщения компилятора надо читать целиком, особенно вторичные метки
... later used here.rustc --explain EXXXX— ваш друг.
Источники
- The Rust Programming Language, глава 4 «Understanding Ownership» — канонический вход в тему, читать первым.
- The Rust Reference: Destructors — точные правила порядка уничтожения и области жизни временных значений.
- The Rustonomicon: Ownership and Lifetimes — та же модель, но со стороны того, что происходит «под капотом».
- RFC 2094: Non-Lexical Lifetimes и обзор Niko Matsakis — почему заимствования перестали быть лексическими и что ещё не решено.
- Rust Compiler Error Index — все коды ошибок с объяснениями; то же локально через
rustc --explain. - «Learning Rust With Entirely Too Many Linked Lists» — лучший способ прочувствовать границы модели на одной структуре данных.
- Jung, Jourdan, Krebbers, Dreyer, «RustBelt: Securing the Foundations of the Rust Programming Language» — формальное доказательство безопасности модели.
- Stacked Borrows и Tree Borrows — операционная семантика алиасинга.
- Jim Blandy, Jason Orendorff, Leonora Tindall, «Programming Rust», 2nd ed. — главы 4–5 разбирают владение и ссылки подробнее любой другой книги.
- Jon Gjengset, «Rust for Rustaceans» — глава «Foundations» о владении, вариантности и внутренней изменяемости для тех, кто уже пишет на Rust.
- Aria Beingessner, «The Pain Of Real Linear Types in Rust» — почему утечки безопасны и что такое «leakpocalypse».
- MSRC: A proactive approach to more secure code и Google: Memory Safe Languages in Android 13 — статистика, ради которой всё это затевалось.
Что дальше
Времена жизни: зачем нужны, как читать аннотации, типичные ошибки — мы несколько раз упирались в вопрос «а откуда взялась эта ссылка и до каких пор она живёт». Следующая глава даёт язык, на котором этот вопрос записывается в сигнатурах: что означает <'a>, почему в большинстве случаев его писать не нужно, как читать error[E0106] и что делать, когда компилятор просит аннотацию, которую вы не понимаете.