Владение, заимствование, время жизни
Что такое владение в Rust и какие у него три правила
Ответ
Владение - набор статических правил, по которым компилятор управляет памятью без использования сборщика мусора или ручного освобождения
Правила владения:
- У каждого значения есть владелец.
- В один момент времени владелец только один(передача владения называется move)
- Когда владелец выходит из скобок, значение освобождается(dropped).
Move в Rust - побайтовое копирование стекового представления и инвалидация исходного биндинга на этапе компиляции.
Например, для String это копирование трёх машинных слов (указатель, длина, ёмкость), а не аллокация. Поэтому move дешёвый и не вызывает Clone.
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.
#[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 в безопасном коде.
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. Время жизни существуют только на этапе проверки типов, в рантайме их нет.
Явные аннотации нужны там, где компилятор не может однозначно связать время жизни входных и выходных параметров.
Самый частый случай: функция возвращает ссылку, и есть несколько кандидатов на входе.
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() >= b.len() { a } else { b }
}Без 'a компилятор не знает, привязать ли результат к a, к b, или к их пересечению. Аннотация говорит: возвращаемая ссылка живёт не дольше пересечения времен жизни входов.
Важно: аннотация не продлевает жизнь данных, она описывает уже существующие отношения. Если написать 'static там, где значение живёт меньше, компилятор это поймает.
struct Parser<'src> {
input: &'src str,
pos: usize
}Тут 'src нужен потому что структура держит ссылку, и компилятор обязан знать, что Parser не переживёт input.
Правило большого пальца: если у функции или структуры одна входная ссылка и одна выходная, аннотации не нужны (правило elision разруливает само). Если несколько входных ссылок и возвращается ссылка, аннотация почти всегда обязательна.
Правила elision времен жизни
Ответ
Elision - набор детерминированных правил, по которым компилятор вставляет время жизни за вас. Применяется только в сигнатурах fn и impl-методах. К телам функций и к struct-определениям не применяется.
Правила:
- Каждой входной ссылке без аннотации присваивается свое время жизни.
- Если ровно одна входная ссылка, её время жизни присваивается всем выходным.
- Если есть
&selfили&mut self, время жизниselfприсваивается всем выходным.
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.
let s: & 'static str = "hello";
let leaked: & 'static mut String = Box::leak(Box::new(String::from("dyn")));Второй: bound T: 'static. Это значит «тип T не содержит нестатических ссылок». Сам объект может быть дропнут хоть сразу, но если внутри есть ссылка, она должна быть 'static.
fn spawn<F: FnOnce() + Send + 'static>(f: F) { /* tokio::spawn */ }Тут 'static не значит «замыкание живёт вечно». Значит «замыкание не захватывает короткоживущих ссылок». String подходит под T: 'static потому что владеет своими данными.
Частая ошибка: объявляют fn make() -> &'static str и возвращают строку, собранную в рантайме. Решается возвратом String, или Box::leak с пониманием, что память не освободится.
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 время жизни заимствования совпадал с лексической областью видимости: ссылка считалась живой до закрывающей скобки, даже если фактически больше не использовалась. Это приводило к ложным отказам.
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 решает.
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 из многих потоков.
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-типов.
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.
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 и освобождает его на своём же закрытии скобок.
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.
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.
struct Slice<'a, T> {
ptr: *const T,
len: usize,
_marker: std::marker::PhantomData<&'a T>,
}Type-state и типизированные ID. Разделить Id<User> и Id<Order>, хотя внутри обе u64. PhantomData делает их разными типами без накладных расходов.
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-подобных типов; реализация для произвольных контейнеров считается анти-паттерном.
let s = String::from("hi");
let r: & str = & s; // deref coercionAsRef<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-представления.
use std::collections::HashMap;
let mut m: HashMap<String, i32> = HashMap::new();
m.insert("a".into(), 1);
m.get("a"); // &str: работает через Borrow<str> для StringSmart pointer - Deref. Принять любое строкоподобное в API - AsRef<str> или AsRef<Path>. Ключи коллекций с альтернативным lookup - Borrow.