Rust Владение и заимствование: модель памяти без сборщика мусора
0%

Владение и заимствование: модель памяти без сборщика мусора

Владение и заимствование: модель памяти без сборщика мусора

В предыдущей статье мы разобрали типы, выражения и функции — но обходили стороной вопрос, который в 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». Это статическая дисциплина, которая позволяет компилятору знать точку освобождения на этапе компиляции. Всё остальное — следствия.

Модель: значение, владелец, область

Три правила, из которых выводится вся глава.

  1. У каждого значения ровно один владелец — переменная, поле структуры, элемент коллекции.
  2. Владелец в каждый момент один. Присваивание другой переменной, передача в функцию, возврат из функции, помещение в коллекцию — всё это перемещает владение (move).
  3. Когда владелец выходит из области видимости, значение уничтожается — вызывается drop, и связанные ресурсы освобождаются.

Формально это аффинные типы: значение можно использовать не более одного раза. Компилятор отслеживает по каждому пути исполнения, где значение ещё живо, а где уже отдано.

Обратите внимание на переход Перемещена --> Владеет. Move не «портит» переменную навсегда: если присвоить ей новое значение, она снова живой владелец. Компилятор рассуждает не о переменных, а о местах (places) в каждой точке потока управления.

Move, Copy и clone: что физически происходит с байтами

Самое частое заблуждение новичка — считать move дорогой операцией. Это не так.

Copy, move и clone: раскладка байтов

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: либо у данных несколько путей доступа, либо кто-то их меняет.

Почему именно так: что было бы иначе

Правило существует не «чтобы было строго». Оно запрещает конкретный сценарий: изменение через один путь доступа делает недействительным другой.

Реаллокация Vec и висячая ссылка

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::clonemalloc + 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.

Связь с соседними треками

Модель владения станет намного понятнее, если посмотреть на неё «снизу».

А теоретический фундамент под всем этим — не «интуиция авторов». Модель формально верифицирована: 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 — ваш друг.

Источники

Что дальше

Времена жизни: зачем нужны, как читать аннотации, типичные ошибки — мы несколько раз упирались в вопрос «а откуда взялась эта ссылка и до каких пор она живёт». Следующая глава даёт язык, на котором этот вопрос записывается в сигнатурах: что означает <'a>, почему в большинстве случаев его писать не нужно, как читать error[E0106] и что делать, когда компилятор просит аннотацию, которую вы не понимаете.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов