Умные указатели: когда единоличного владения недостаточно
Что вы узнаете:
Box<T>,Rc<T>,Arc<T>,Cell<T>,RefCell<T>иCow<'a, T>— когда что использовать, как они соотносятся со ссылками, которыми управляет GC в C#,Dropкак аналогIDisposableв Rust, приведение черезDeref, а также дерево решений для выбора подходящего умного указателя.Сложность: 🔴 Продвинутый
В C# каждый объект по сути подсчитывается сборщиком мусора. В Rust по умолчанию действует единоличное владение, но иногда нужны разделяемое владение, выделение в куче или внутренняя изменяемость. Для этого и существуют умные указатели.
Box<T> — простое выделение в куче
#![allow(unused)]
fn main() {
// Выделение на стеке (по умолчанию в Rust)
let x = 42; // на стеке
// Выделение в куче через Box
let y = Box::new(42); // в куче, как `new int(42)` в C# (упаковка)
println!("{}", y); // автоматическое разыменование: выводит 42
// Типичное применение: рекурсивные типы (размер неизвестен на этапе компиляции)
#[derive(Debug)]
enum List {
Cons(i32, Box<List>), // Box даёт указатель известного размера
Nil,
}
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
}
// C# — всё уже в куче (ссылочные типы)
// Box<T> нужен в Rust только потому, что стек — это умолчание
var list = new LinkedListNode<int>(1); // всегда выделяется в куче
Rc<T> — разделяемое владение (один поток)
#![allow(unused)]
fn main() {
use std::rc::Rc;
// Несколько владельцев одних и тех же данных — как несколько ссылок в C#
let shared = Rc::new(vec![1, 2, 3]);
let clone1 = Rc::clone(&shared); // счётчик ссылок: 2
let clone2 = Rc::clone(&shared); // счётчик ссылок: 3
println!("Count: {}", Rc::strong_count(&shared)); // 3
// Данные уничтожаются, когда выходит из области видимости последний Rc
// Типичное применение: общая конфигурация, узлы графа, древовидные структуры
}
Arc<T> — разделяемое владение (потокобезопасное)
#![allow(unused)]
fn main() {
use std::sync::Arc;
use std::thread;
// Arc = Atomic Reference Counting — безопасно разделяется между потоками
let data = Arc::new(vec![1, 2, 3]);
let handles: Vec<_> = (0..3).map(|i| {
let data = Arc::clone(&data);
thread::spawn(move || {
println!("Thread {i}: {:?}", data);
})
}).collect();
for h in handles { h.join().unwrap(); }
}
// C# — все ссылки потокобезопасны по умолчанию (этим занимается GC)
var data = new List<int> { 1, 2, 3 };
// Можно свободно разделять между потоками (но мутация по-прежнему небезопасна!)
Cell<T> и RefCell<T> — внутренняя изменяемость
#![allow(unused)]
fn main() {
use std::cell::RefCell;
// Иногда нужно изменять данные за разделяемой ссылкой.
// RefCell переносит проверку заимствований из времени компиляции во время выполнения.
struct Logger {
entries: RefCell<Vec<String>>,
}
impl Logger {
fn new() -> Self {
Logger { entries: RefCell::new(Vec::new()) }
}
fn log(&self, msg: &str) { // &self, а не &mut self!
self.entries.borrow_mut().push(msg.to_string());
}
fn dump(&self) {
for entry in self.entries.borrow().iter() {
println!("{entry}");
}
}
}
// ⚠️ RefCell вызывает панику во время выполнения, если нарушены правила заимствования
// Используйте осторожно — по возможности предпочитайте проверку на этапе компиляции
}
Cow<’a, str> — копирование при записи (Clone on Write)
#![allow(unused)]
fn main() {
use std::borrow::Cow;
// Иногда есть &str, который МОЖЕТ понадобиться превратить в String
fn normalize(input: &str) -> Cow<'_, str> {
if input.contains('\t') {
// Выделяем память, только когда нужно изменить данные
Cow::Owned(input.replace('\t', " "))
} else {
// Заимствуем исходные данные — без выделения памяти
Cow::Borrowed(input)
}
}
let clean = normalize("hello"); // Cow::Borrowed — без выделения памяти
let dirty = normalize("hello\tworld"); // Cow::Owned — выделено
// Оба можно использовать как &str через Deref
println!("{clean} / {dirty}");
}
Drop: аналог IDisposable в Rust
В C# очисткой ресурсов занимаются IDisposable и using. Аналог в Rust — трейт Drop, но он автоматический, а не включаемый по желанию:
// C# — нужно не забыть использовать 'using' или вызвать Dispose()
using var file = File.OpenRead("data.bin");
// Dispose() вызывается в конце области видимости
// Забыть 'using' — значит получить утечку ресурса!
var file2 = File.OpenRead("data.bin");
// GC в итоге финализирует объект, но момент непредсказуем
// Rust — Drop выполняется автоматически, когда значение выходит из области видимости
{
let file = File::open("data.bin")?;
// работаем с file...
} // file.drop() вызывается ЗДЕСЬ, детерминированно — 'using' не нужен
// Собственный Drop (аналог реализации IDisposable)
struct TempFile {
path: std::path::PathBuf,
}
impl Drop for TempFile {
fn drop(&mut self) {
// Гарантированно выполняется, когда TempFile выходит из области видимости
let _ = std::fs::remove_file(&self.path);
println!("Cleaned up {:?}", self.path);
}
}
fn main() {
let tmp = TempFile { path: "scratch.tmp".into() };
// ... используем tmp ...
} // scratch.tmp удаляется автоматически здесь
Ключевое отличие от C#: в Rust любой тип может иметь детерминированную очистку. Вы никогда не забудете using, потому что забывать нечего — Drop выполняется, когда владелец выходит из области видимости. Этот паттерн называется RAII (Resource Acquisition Is Initialization — получение ресурса есть инициализация).
Правило: если ваш тип хранит ресурс (дескриптор файла, сетевое соединение, охранник блокировки, временный файл), реализуйте
Drop. Система владения гарантирует, что он выполнится ровно один раз.
Приведение через Deref: автоматическое разворачивание умных указателей
Rust автоматически «разворачивает» умные указатели, когда вы вызываете методы или передаёте их в функции. Это называется приведением через Deref (Deref coercion):
#![allow(unused)]
fn main() {
let boxed: Box<String> = Box::new(String::from("hello"));
// Цепочка приведения через Deref: Box<String> → String → str
println!("Length: {}", boxed.len()); // вызывается str::len() — автоматическое разыменование!
fn greet(name: &str) {
println!("Hello, {name}");
}
let s = String::from("Alice");
greet(&s); // &String → &str через приведение Deref
greet(&boxed); // &Box<String> → &String → &str — два уровня!
}
// В C# аналога нет — понадобились бы явные приведения или .ToString()
// Ближайшее — неявные операторы преобразования, но их нужно явно определить
Почему это важно: вы можете передать &String там, где ожидается &str, &Vec<T> там, где ожидается &[T], и &Box<T> там, где ожидается &T, — и всё это без явного преобразования. Поэтому API на Rust обычно принимают &str и &[T], а не &String и &Vec<T>.
Rc против Arc: что выбрать
Rc<T> | Arc<T> | |
|---|---|---|
| Потокобезопасность | ❌ Только один поток | ✅ Потокобезопасен (атомарные операции) |
| Накладные расходы | Ниже (неатомарный счётчик ссылок) | Выше (атомарный счётчик ссылок) |
| Проверка компилятором | Не скомпилируется при передаче в thread::spawn | Работает везде |
| Сочетается с | RefCell<T> для изменения | Mutex<T> или RwLock<T> для изменения |
Эмпирическое правило: начинайте с Rc. Компилятор подскажет, если понадобится Arc.
Дерево решений: какой умный указатель выбрать?
graph TD
START["Нужно разделяемое владение<br/>или выделение в куче?"]
HEAP["Нужно только выделение в куче?"]
SHARED["Нужно разделяемое владение?"]
THREADED["Разделяется между потоками?"]
MUTABLE["Нужна внутренняя изменяемость?"]
MAYBE_OWN["Иногда заимствуется,<br/>иногда владеется?"]
BOX["Используйте Box<T>"]
RC["Используйте Rc<T>"]
ARC["Используйте Arc<T>"]
REFCELL["Используйте RefCell<T><br/>(или Rc<RefCell<T>>)"]
MUTEX["Используйте Arc<Mutex<T>>"]
COW["Используйте Cow<'a, T>"]
OWN["Используйте владеемый тип<br/>(String, Vec и т. д.)"]
START -->|Да| HEAP
START -->|Нет| OWN
HEAP -->|Да| BOX
HEAP -->|Разделяемое| SHARED
SHARED -->|Один поток| RC
SHARED -->|Несколько потоков| THREADED
THREADED -->|Только чтение| ARC
THREADED -->|Чтение и запись| MUTEX
RC -->|Нужна мутация?| MUTABLE
MUTABLE -->|Да| REFCELL
MAYBE_OWN -->|Да| COW
style BOX fill:#e3f2fd,color:#000
style RC fill:#e8f5e8,color:#000
style ARC fill:#c8e6c9,color:#000
style REFCELL fill:#fff3e0,color:#000
style MUTEX fill:#fff3e0,color:#000
style COW fill:#e3f2fd,color:#000
style OWN fill:#f5f5f5,color:#000
🏋️ Упражнение: выберите подходящий умный указатель (нажмите, чтобы раскрыть)
Задача: для каждого сценария выберите подходящий умный указатель и объясните почему.
- Рекурсивная древовидная структура данных
- Общий объект конфигурации, который читают несколько компонентов (один поток)
- Счётчик запросов, разделяемый между обработчиками HTTP-запросов в разных потоках
- Кэш, который может возвращать заимствованные или владеемые строки
- Буфер логов, который нужно изменять через разделяемую ссылку
🔑 Решение
Box<T>— рекурсивным типам нужна косвенная адресация, чтобы размер был известен на этапе компиляцииRc<T>— разделяемый доступ только для чтения, один поток, накладные расходыArcне нужныArc<Mutex<u64>>— разделяется между потоками (Arc) с изменением (Mutex)Cow<'a, str>— иногда возвращает&str(попадание в кэш), иногдаString(промах кэша)RefCell<Vec<String>>— внутренняя изменяемость за&self(один поток)
Эмпирическое правило: начинайте с владеемых типов. Берите Box, когда нужна косвенная адресация, Rc/Arc, когда нужно разделение, RefCell/Mutex, когда нужна внутренняя изменяемость, и Cow, когда хотите работать без копирования в типичном случае.