Skip to content

Владение, заимствование, время жизни

Что такое владение в Rust и какие у него три правила

Ответ

Владение - набор статических правил, по которым компилятор управляет памятью без использования сборщика мусора или ручного освобождения

Правила владения:

  • У каждого значения есть владелец.
  • В один момент времени владелец только один(передача владения называется move)
  • Когда владелец выходит из скобок, значение освобождается(dropped).

Move в Rust - побайтовое копирование стекового представления и инвалидация исходного биндинга на этапе компиляции.

Например, для String это копирование трёх машинных слов (указатель, длина, ёмкость), а не аллокация. Поэтому move дешёвый и не вызывает Clone.

rust
fn take(s: String) { println!("{s}"); }

fn main() {
    let a = String::from("hi");
    take(a);
    // println!("{a}"); // E0382: borrow of moved value
}

Move не копирует кучу. Глубокое копирование делает Clone. Для Copy-типов (примитивы, &T, кортежи из Copy) семантика побитовая и исходный биндинг остаётся валидным, потому что Copy и Drop взаимоисключающие.

Отличия move и copy.

Ответ

На уровне ассемблера Move и Copy делают одно: memcpy стекового представления. Разница в том, что компилятор делает с исходным биндингом. После move исходное значение инвалидировано и обращение даёт E0382. После copy исходно значение остаётся валидным.

Какие типы реализуют Copy

Ответ

Copy реализуют целочисленные и плавающие типы, bool, char, разделяемые ссылки &T, неизменяемые сырые указатели, кортежи и массивы из Copy-полей, Option<T> и Result<T, E> если T и E тоже Copy.

Copy несовместим с Drop. Если у типа есть деструктор, компилятор не даст реализовать Copy, иначе один и тот же ресурс закрылся бы дважды. Поэтому String, Vec, Box, File всегда move-only.

rust
#[derive(Copy, Clone)]
struct Point {
    x: f32,
    y: f32
}

fn main() {
    let p = Point { x: 1.0, y: 2.0 };
    let q = p; // copy
    let r = p; // тоже copy, p всё ещё валиден
    println!("{} {} {}", p.x, q.x, r.x);
}

Когда добавлять Copy.

Ответ

Для маленьких POD(Plain Old Data)-типов, где побайтовое копирование действительно дешёвое и семантически безопасное. Для [u8; 4096] Copy технически возможен, но любое присваивание скопирует 4 КБ.

Разница между &T и &mut T, какие правила заимствования действуют одновременно

Ответ

&T - shared reference, &mut T - exclusive reference. На уровне типа это разные категории, а не модификатор изменяемости. &mut гарантирует эксклюзивный доступ: пока живёт &mut T, других ссылок на это значение не существует.

Правило XOR. В каждой точке программы либо одна &mut T, либо сколько угодно &T. Это инвариант, на котором держится noalias-оптимизация и отсутствие data race в безопасном коде.

rust
fn main() {
    let mut v = vec![1, 2, 3];
    let r1 = &v;
    let r2 = &v; // несколько shared ок
    println!("{r1:?} {r2:?}");
    let m = &mut v; // r1 и r2 уже не используются благодаря NLL
    m.push(4);
}

На уровне LLVM &mut T транслируется в указатель с атрибутом noalias, что даёт компилятору право переупорядочивать чтения и записи. Нарушение этого инварианта через unsafe приводит к UB, даже если код «выглядит правильно».

Что такое время жизни и зачем нужны явные аннотации

Ответ

Время жизни - область видимости, в течение которой ссылка гарантированно валидна. Каждая ссылка имеет время жизни, выведенный компилятором или указанный явно через 'a. Время жизни существуют только на этапе проверки типов, в рантайме их нет.

Явные аннотации нужны там, где компилятор не может однозначно связать время жизни входных и выходных параметров.

Самый частый случай: функция возвращает ссылку, и есть несколько кандидатов на входе.

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

Без 'a компилятор не знает, привязать ли результат к a, к b, или к их пересечению. Аннотация говорит: возвращаемая ссылка живёт не дольше пересечения времен жизни входов.

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

rust
struct Parser<'src> {
    input: &'src str,
    pos: usize
}

Тут 'src нужен потому что структура держит ссылку, и компилятор обязан знать, что Parser не переживёт input.

Правило большого пальца: если у функции или структуры одна входная ссылка и одна выходная, аннотации не нужны (правило elision разруливает само). Если несколько входных ссылок и возвращается ссылка, аннотация почти всегда обязательна.

Правила elision времен жизни

Ответ

Elision - набор детерминированных правил, по которым компилятор вставляет время жизни за вас. Применяется только в сигнатурах fn и impl-методах. К телам функций и к struct-определениям не применяется.

Правила:

  1. Каждой входной ссылке без аннотации присваивается свое время жизни.
  2. Если ровно одна входная ссылка, её время жизни присваивается всем выходным.
  3. Если есть &self или &mut self, время жизни self присваивается всем выходным.
rust
fn first(s: &str) -> &str { &s[..1] } // (1) и (2)
impl Cache { fn get(&self, key: &str) -> &str { /* */ } } // (1) и (3)

Когда elision не сработает. Несколько входных ссылок без &self - второе правило не применяется. Возвращается ссылка, не связанная ни с одним входом - тоже нужна аннотация (часто это 'static для строковых литералов).

Что такое 'static

Ответ

'static встречается в двух разных контекстах.

Первый: время жизни 'static для ссылок. &'static T указывает на данные, живущие всё время работы программы. Строковые литералы имеют тип &'static str потому что лежат в .rodata. Также 'static получают глобальные static-переменные и значения, помещённые в Box::leak.

rust
let s: & 'static str = "hello";
let leaked: & 'static mut String = Box::leak(Box::new(String::from("dyn")));

Второй: bound T: 'static. Это значит «тип T не содержит нестатических ссылок». Сам объект может быть дропнут хоть сразу, но если внутри есть ссылка, она должна быть 'static.

rust
fn spawn<F: FnOnce() + Send + 'static>(f: F) { /* tokio::spawn */ }

Тут 'static не значит «замыкание живёт вечно». Значит «замыкание не захватывает короткоживущих ссылок». String подходит под T: 'static потому что владеет своими данными.

Частая ошибка: объявляют fn make() -> &'static str и возвращают строку, собранную в рантайме. Решается возвратом String, или Box::leak с пониманием, что память не освободится.

rust
fn make(value: i32) -> &'static str {
    &format!("{}", value) // cannot return reference to temporary value E0515
}

Что такое NLL и как работает borrow checker сейчас

Ответ

NLL (Non-Lexical Lifetimes) появились в 2018 edition и фактически переписали borrow checker. До NLL время жизни заимствования совпадал с лексической областью видимости: ссылка считалась живой до закрывающей скобки, даже если фактически больше не использовалась. Это приводило к ложным отказам.

rust
let mut v = vec![1, 2, 3];
let r = & v[0];
println!("{r}");
v.push(4); // до NLL ошибка, после NLL ок: r больше не используется

Сейчас borrow checker работает на MIR и считает время жизни заимствования по последней точке использования. Технически - анализ потока управления на CFG, где компилятор строит регионы и проверяет, не пересекаются ли несовместимые заимствования.

Следующий шаг - Polonius, реализация borrow checker на datalog. Он принимает несколько паттернов, которые NLL ещё отвергает, в первую очередь возврат ссылок через условные ветви.

Если код выглядит корректным, но NLL не пропускает, помогает либо явное определение области видимости в { ... }, либо вынесение части кода в отдельную функцию, чтобы время жизни заканчивалось раньше.

Что делает Box

Ответ

Box<T> - владеющий указатель в кучу. Внутри одно машинное слово - указатель на аллокацию из глобального аллокатора. При дропе Box вызывает деструктор T и возвращает память аллокатору.

Когда нужен Box

Ответ

Рекурсивные типы. enum List { Cons(i32, Box<List>), Nil }. Без Box размер был бы бесконечен, компилятор не сможет вычислить layout.

Большие значения. Перемещение Box<[u8; 1_000_000]> копирует одно слово, а не мегабайт.

Type erasure через trait object. Box<dyn Trait> хранит толстый указатель (data + vtable) и позволяет складывать в одну коллекцию разнородные реализации.

Возврат типа неизвестного размера. fn make() -> Box<dyn Future<Output = u32>> - компилятор не знает размер конкретного future, Box решает.

rust
trait Shape { fn area(&self) -> f64; }
struct Circle(f64);
impl Shape for Circle {
    fn area(&self) -> f64 { std::f64::consts::PI * self.0 * self.0 }
}

let shapes: Vec<Box<dyn Shape> > = vec![Box::new(Circle(1.0))];

Чего Box не делает: не даёт shared ownership (для этого Rc/Arc), не даёт мутацию через shared (для этого Cell/ RefCell), не делает thread-safe (это Arc и Mutex).

Rc и Arc, в чем разница и когда что выбирать

Ответ

Rc<T> и Arc<T> - shared-ownership через подсчёт ссылок. Разница в том, как обновляется счётчик.

Rc использует обычные неатомарные usize-операции. Дешевле на инкременте и декременте, но небезопасен для передачи между потоками. Rc<T> не реализует Send и не реализует Sync, попытка отправить его в thread::spawn даст ошибку компиляции.

Arc использует атомарные операции (AtomicUsize с Relaxed на инкременте и Release/Acquire на декременте). Реализует Send и Sync при T: Send + Sync. Атомарный декремент стоит дороже обычного, особенно при contention из многих потоков.

rust
use std::sync::Arc;
let data = Arc::new(vec![1, 2, 3]);
let d2 = Arc::clone( & data);
std::thread::spawn( move | | println!("{d2:?}"));

У каждого внутри два счётчика, strong и weak.

Weak-ссылки нужны для разрыва циклов. Rc::new_cyclic и Arc::new_cyclic создают значение, которое может ссылаться на самого себя через Weak.

Когда что выбирать. По умолчанию однопоточно - Rc. Данные пересекают границу потоков - Arc. Внутри tokio::spawn замыкание должно быть Send + 'static, поэтому Arc. Иммутабельные read-heavy данные - Arc отлично работает. Нужна мутация - Arc<Mutex<T>> или Arc<RwLock<T>>.

Подводный камень: цикл из Rc/Arc - утечка. Если A владеет Rc<B>, а B владеет Rc<A>, счётчики никогда не дойдут до нуля. Дерево с обратными ссылками родителей - классический случай, лечится Weak для родительских ссылок.

Что такое interior mutability и какие типы её реализуют

Ответ

Interior mutability - шаблон, при котором значение мутируется через shared-ссылку &T. Это нарушает обычное правило XOR на уровне API, но безопасность обеспечивается дополнительной проверкой: в рантайме или через системные примитивы.

Cell<T> для Copy-типов. Внутри UnsafeCell, API через get/set/replace, без выдачи ссылок наружу. Никакой проверки в рантайме, потому что без ссылок нет и aliasing-проблем.

RefCell<T> для произвольных типов. Раздаёт Ref<T> и RefMut<T> через borrow и borrow_mut, считает заимствования в рантайме. Нарушение XOR (например, два borrow_mut подряд) даёт панику. Не Sync, только для одного потока.

Mutex<T> и RwLock<T> - многопоточные аналоги. Блокируют поток, реализуют Sync.

OnceCell<T> и LazyLock<T> для однократной инициализации.

atomic::Atomic* для lock-free мутации Copy-типов.

rust
use std::cell::RefCell;
use std::collections::HashMap;

struct Cache {
    map: RefCell<HashMap<String, String>>
}
impl Cache {
    fn get_or_insert(&self, k: &str, v: String) -> String {
        let mut m = self.map.borrow_mut();
        m.entry(k.into()).or_insert(v).clone()
    }
}

Где нужно. Структуры, семантически иммутабельные снаружи, но кэширующие что-то внутри (memoization, ленивая инициализация). Графы и деревья с локальными обновлениями.

Все эти типы построены на UnsafeCell - единственном примитиве языка, через который компилятор разрешает получить &mut T из &UnsafeCell<T>. Прямое использование UnsafeCell требует unsafe и ручного соблюдения правил псевдонимов.

Что такое Cow

Ответ

Cow<'a, B> (Clone-on-Write) - enum Borrowed(&'a B) | Owned(B::Owned). Идея: пока изменения не нужны, держим shared-ссылку; как только нужно мутировать, делаем to_mut, который при необходимости клонирует данные в Owned-вариант.

Где применяется на практике.

Парсинг и нормализация. Если вход уже валидный, возвращаем Cow::Borrowed(input) без аллокации. Если нужно подправить, переходим в Owned.

rust
use std::borrow::Cow;

fn normalize(s: &str) -> Cow<'_, str> {
    if s.contains('\r') { Cow::Owned(s.replace('\r', "")) } else { Cow::Borrowed(s) }
}

API, который может вернуть либо позаимствованную, либо собственную строку. Path::to_string_lossy возвращаетCow<str>: если путь валидный UTF-8 - Borrowed, иначе аллоцирует с заменой невалидных байтов.

Deserialization с zero-copy. serde поддерживает Cow<'a, str>: если входной буфер требует обработки эскейпов - Owned, если нет - Borrowed на исходный буфер.

Где не помогает. Если на горячем пути почти всегда нужна мутация, Cow только добавляет ветвление. Когда заведомо нужен String, его и берите.

Что такое Drop и можно ли вызвать его вручную

Ответ

Drop - трейт с одним методом fn drop(&mut self). Компилятор вызывает его в точке выхода значения из области владения. Деструктор гарантированно вызывается при нормальном завершении и при unwinding-панике (если panic=unwind), но не вызывается при mem::forget и при abort процесса.

Руками drop() метод трейта вызвать нельзя - это даст E0040. Если бы было можно, после ручного вызова компилятор всё равно вставил бы свой, и деструктор отработал бы дважды. Для досрочного освобождения есть свободная функция std::mem::drop(x), которая просто принимает значение по move и освобождает его на своём же закрытии скобок.

rust
struct Guard;
impl Drop for Guard { fn drop(&mut self) { println!("bye"); } }

fn main() {
    let g = Guard;
    drop(g); // вызовет деструктор сейчас
    println!("after");
}

Детали. Порядок очистки полей структуры - в порядке объявления. Порядок уничтожения локальных переменных - в обратном порядке создания. Это критично для RAII-гардов: лок должен уничтожаться позже данных, которые он защищает.

В Drop::drop нельзя паниковать, если уже идёт паника: double panic ведёт к abort. Поэтому в деструкторах не вызывают .unwrap и не делают потенциально падающих операций.

Конфликт с move. После drop(x) биндинг инвалидирован, к нему уже не обратиться. И тип с Drop не может быть Copy.

Move в замыканиях и Fn, FnMut, FnOnce

Ответ

Замыкание - анонимный тип с реализацией одного из трёх трейтов: Fn, FnMut или FnOnce. Компилятор автоматически выбирает наиболее «лёгкий» из доступных, исходя из того, как замыкание использует захваченные переменные.

  • FnOnce вызывается ровно один раз, потому что забирает захваченные значения по move внутрь себя или внутрь вызова.
  • FnMut вызывается многократно и может мутировать захваченные переменные, требует &mut к самому замыканию.
  • Fn вызывается многократно и не мутирует, достаточно &.

Иерархия: Fn: FnMut: FnOnce. Если замыкание реализует Fn, оно автоматически и FnMut, и FnOnce.

rust
let s = String::from("hi");
let f1 = | | println!("{s}"); // Fn: захват по &

let mut v = vec![1];
let mut f2 = | | v.push(2); // FnMut: захват по &mut

let f3 = move | | drop(s); // FnOnce: потребляет s

Ключевое слово move форсирует захват по значению независимо от того, как используются переменные. Это нужно для thread::spawn и tokio::spawn, потому что замыкание должно быть 'static и не может держать ссылок на стек породившего потока.

Подводный камень: move не делает замыкание FnOnce автоматически. Если внутри move-замыкания только читаются Copy-значения, оно останется Fn. Тип трейта определяется тем, что замыкание делает с захваченным, а move только меняет способ захвата.

Что такое PhantomData и где его применять

Ответ

PhantomData<T> - маркер нулевого размера, который говорит компилятору: «считай, что эта структура владеет или использует T, хотя физически его не хранит». Без хранения данных, но с эффектом на dropck, variance и auto traits.

Где это пригождается.

Привязка времени жизни к структуре, которая держит сырой указатель. Без PhantomData<&'a T> компилятор не свяжет 'a со структурой, и можно получить висящий указатель без жалоб от borrow checker.

rust
struct Slice<'a, T> {
    ptr: *const T,
    len: usize,
    _marker: std::marker::PhantomData<&'a T>,
}

Type-state и типизированные ID. Разделить Id<User> и Id<Order>, хотя внутри обе u64. PhantomData делает их разными типами без накладных расходов.

rust
use std::marker::PhantomData;
struct Id<T> {
    value: u64,
    _t: PhantomData<T>
}
struct User;
struct Order;

Контроль variance и Send/Sync. PhantomData<*const T> снимает Send/Sync, PhantomData<fn() -> T> делает тип ковариантным по T. Это инструменты для авторов unsafe-кода, которые знают, какую дисперсию они хотят.

Что эта штука не делает. PhantomData<T> не вызывает деструктор T, потому что значение не хранится. Если ваш тип логически владеет T и должен его дропать, нужен PhantomData<T> плюс корректная работа с dropck, либо хранение реального T.

Borrow, AsRef, Deref, чем они отличаются

Ответ

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

Deref - «прозрачное разыменование». Реализуя Deref<Target = T>, вы заявляете, что ваш тип семантически является T. Компилятор применяет deref coercion: &Box<T> неявно становится &T, &String становится &str. Метод-резолюция тоже идёт по deref-цепочке. Реализуется только для smart pointer-подобных типов; реализация для произвольных контейнеров считается анти-паттерном.

rust
let s = String::from("hi");
let r: & str = & s; // deref coercion

AsRef<T> - «дешёвое преобразование ссылки в ссылку». Не требует identity: AsRef<str> есть у String, у &str, у PathBuf. Используется в обобщённых API: fn read<P: AsRef<Path>>(p: P) принимает что угодно, что превращается в &Path.

Borrow<T> похож на AsRef, но с дополнительным контрактом: Hash, Eq, Ord у Self и у Borrow::Target должны давать одинаковые результаты. Это нужно для HashMap::get: ключ типа String ищется по &str, и хэш строки должен совпадать с хэшем её &str-представления.

rust
use std::collections::HashMap;

let mut m: HashMap<String, i32> = HashMap::new();
m.insert("a".into(), 1);
m.get("a"); // &str: работает через Borrow<str> для String

Smart pointer - Deref. Принять любое строкоподобное в API - AsRef<str> или AsRef<Path>. Ключи коллекций с альтернативным lookup - Borrow.