Розумні вказівники в Rust: Box, Rc, Arc і RefCell у 2026

Розумні вказівники Rust Box, Rc, Arc і RefCell з поясненнями, компільованими прикладами 2026 року, таблицею вибору та типовими питаннями співбесід.

Схема керування пам'яттю розумних вказівників Rust Box, Rc, Arc і RefCell

Розумні вказівники в Rust — це інструменти, що відкривають шаблони володіння, які сам borrow checker виразити не здатен: розміщення в купі, спільне володіння та зміну за спільним посиланням. Тоді як звичайне посилання (&T) лише позичає значення, розумний вказівник володіє своїми даними й додає до них додаткову поведінку. Цей посібник розбирає чотири типи, з якими кожен розробник на Rust стикається у продакшені та на співбесідах: Box, Rc, Arc і RefCell, разом із компільованими прикладами для редакції Rust 2024.

Стисле резюме в один рядок

Box<T> застосовують для розміщення в купі з одним власником, Rc<T> — для спільного володіння в межах одного потоку, Arc<T> — для спільного володіння між потоками, а RefCell<T> — для зміни значення через спільне посилання. Пари Rc<RefCell<T>> і Arc<Mutex<T>> покривають спільний змінюваний стан.

Що таке розумні вказівники в Rust

Розумний вказівник — це структура, що поводиться як вказівник, але несе додаткові метадані чи можливості. Більшість реалізують трейт Deref, тож *pointer і виклики методів працюють так, ніби вказівник є звичайним посиланням, а також трейт Drop, тож очищення відбувається автоматично, коли значення виходить із області видимості. Стандартна бібліотека постачає чотири типи, розглянуті тут, а їх розуміння спирається на впевнене володіння темою володіння і позичання, що вирішує, хто і коли звільняє кожне виділення.

Ключова відмінність від посилання — це володіння. &T ніколи не володіє даними, на які вказує, тож не може пережити значення. Розумний вказівник володіє даними, керує їхнім часом життя та звільняє їх детерміновано. Розділ книги Rust про розумні вказівники розглядає їх як категорію саме тому, що вони мають спільну форму: володіння плюс поведінка.

Box<T>: розміщення в купі для рекурсивних типів і типів із відомим розміром

Box<T> — найпростіший розумний вказівник. Він зберігає значення в купі та тримає вказівник на нього у стеку, має одного власника й не додає накладних витрат під час виконання, окрім самого виділення. Його найпоширеніше завдання — надати рекурсивним типам відомий розмір: тип, що містить сам себе безпосередньо, був би нескінченно великим, але Box — це лише вказівник, тож його розмір фіксований незалежно від того, на що він вказує.

Наведений нижче приклад визначає бінарне дерево. Кожен Node тримає двох нащадків, і без непрямості компілятор не може обчислити розмір Tree. Загортання кожного нащадка в Box розриває рекурсію на рівні типу.

tree.rsrust
#[derive(Debug)]
enum Tree {
    Leaf(i32),
    // Box<Tree> puts each child on the heap, so Node has a fixed size (two pointers).
    Node(Box<Tree>, Box<Tree>),
}

fn sum(tree: &Tree) -> i32 {
    match tree {
        Tree::Leaf(value) => *value,                        // base case: return the leaf
        Tree::Node(left, right) => sum(left) + sum(right),  // recurse into both children
    }
}

fn main() {
    // Build (1) + ((2) + (3)) = 6
    let tree = Tree::Node(
        Box::new(Tree::Leaf(1)),
        Box::new(Tree::Node(
            Box::new(Tree::Leaf(2)),
            Box::new(Tree::Leaf(3)),
        )),
    );
    println!("sum = {}", sum(&tree)); // sum = 6
}

Box також важливий, коли переміщення великого значення було б витратним або коли повертається трейт-об'єкт, як-от Box<dyn Error>. У кожному випадку правило те саме: один власник, який звільняється автоматично, коли Box знищується.

Rc<T>: спільне володіння в однопотоковому коді

Іноді значенню потрібно кілька власників, жоден з яких не є очевидно останнім, хто його використовує. Rc<T> (з підрахунком посилань) розв'язує це, тримаючи сильний лічильник наявних власників. Rc::clone збільшує цей лічильник, не копіюючи базові дані, а кожне знищення його зменшує. Коли лічильник досягає нуля, значення звільняється.

Прикладом є об'єкт конфігурації, який читають кілька робітників. Кожен робітник має тримати конфігурацію стільки, скільки йому потрібно, а виділення має зникнути лише тоді, коли зникне останній робітник.

shared_config.rsrust
use std::rc::Rc;

#[derive(Debug)]
struct Config {
    endpoint: String,
    timeout_ms: u32,
}

fn main() {
    // Rc::new moves Config onto the heap with a strong count of 1.
    let config = Rc::new(Config {
        endpoint: "https://api.example.com".to_string(),
        timeout_ms: 5000,
    });

    // Rc::clone only bumps the reference count; it does not deep-copy Config.
    let worker_a = Rc::clone(&config);
    let worker_b = Rc::clone(&config);

    // All three handles point to the same allocation.
    println!("endpoint: {}", worker_a.endpoint);
    println!("timeout:  {}", worker_b.timeout_ms);

    // strong_count reports how many owners currently hold the value.
    println!("owners: {}", Rc::strong_count(&config)); // owners: 3
}

Явний виклик Rc::clone (замість config.clone()) є ідіоматичним: він сигналізує читачам, що операція дешева й лише торкається лічильника. Заковика в тому, що Rc<T> видає тільки спільні, незмінні посилання. Він не може змінювати значення, яким ділиться, і його небезпечно надсилати між потоками. Документація Rc роз'яснює обидва обмеження.

RefCell<T> і внутрішня змінюваність у Rust

Rust зазвичай вимагає, щоб значення або було спільним через багато незмінних посилань, або змінювалося через рівно одне змінне посилання, і перевіряє це під час компіляції. RefCell<T> забезпечує внутрішню змінюваність: він дозволяє коду змінювати значення через спільне посилання, переносячи ту саму перевірку на час виконання. borrow() повертає спільний охоронець читання; borrow_mut() повертає ексклюзивний охоронець запису. Правила ідентичні, але порушення викликають паніку замість помилки компіляції.

У поєднанні з Rc це дає канонічний однопотоковий шаблон спільного змінюваного стану Rc<RefCell<T>>: кілька власників, які всі можуть оновлювати те саме значення.

counter.rsrust
use std::rc::Rc;
use std::cell::RefCell;

// Rc gives shared ownership; RefCell allows mutation through a shared reference.
type SharedCounter = Rc<RefCell<u32>>;

fn increment(counter: &SharedCounter) {
    // borrow_mut() hands out an exclusive reference, checked at runtime.
    *counter.borrow_mut() += 1;
}

fn main() {
    let counter: SharedCounter = Rc::new(RefCell::new(0));

    let handle_a = Rc::clone(&counter);
    let handle_b = Rc::clone(&counter);

    increment(&handle_a);
    increment(&handle_b);
    increment(&counter);

    // borrow() gives a shared read guard; the value is now 3.
    println!("count = {}", counter.borrow()); // count = 3
}
RefCell переносить перевірку позичання на час виконання

Виклик borrow_mut(), поки живий інший охоронець borrow() чи borrow_mut(), викликає паніку з повідомленням already borrowed: BorrowMutError. Гарантії безпеки зберігаються, але логічна помилка, яку компілятор би виявив, стає збоєм під час виконання. Охоронці варто тримати короткоживучими й уникати утримання позичання протягом виклику функції, що може повторно ввійти в той самий RefCell.

Внутрішня змінюваність — це також місце, де ховаються цикли посилань. Два значення Rc<RefCell<T>>, що вказують одне на одне, назавжди тримають сильний лічильник одне одного вище нуля, тож жодне ніколи не звільняється. Розв'язанням є Weak<T>, дескриптор без володіння, який не впливає на сильний лічильник; класичний розбір міститься в Learn Rust With Entirely Too Many Linked Lists.

Arc<T>: потокобезпечний підрахунок посилань для конкурентності

Arc<T> (з атомарним підрахунком посилань) — це багатопотоковий родич Rc<T>. Публічний API майже ідентичний, але лічильник використовує атомарні операції, тож клонування та знищення Arc з кількох потоків одночасно залишається коректним. Саме через цю атомарність Arc обирають лише тоді, коли спільне використання перетинає межу потоку: атомарне збільшення відчутно повільніше за звичайне, яке використовує Rc.

Оскільки Arc<T> усе ще видає незмінні посилання, зміна між потоками потребує примітиву синхронізації. Mutex<T> — звичний партнер, що дає шаблон Arc<Mutex<T>>, який віддзеркалює Rc<RefCell<T>> для конкурентного коду. Приклад запускає чотири потоки, кожен з яких тисячу разів збільшує спільну суму.

parallel_sum.rsrust
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    // Arc is safe to share across threads; Mutex serializes access to the inner value.
    let total = Arc::new(Mutex::new(0u64));
    let mut handles = Vec::new();

    for _ in 0..4 {
        // Each thread gets its own Arc handle (an atomic count bump).
        let total = Arc::clone(&total);
        handles.push(thread::spawn(move || {
            for _ in 0..1000 {
                // lock() blocks until the mutex is free, then returns a guard.
                let mut value = total.lock().unwrap();
                *value += 1;
            }
        }));
    }

    for handle in handles {
        handle.join().unwrap(); // wait for every thread before reading
    }

    println!("total = {}", *total.lock().unwrap()); // total = 4000
}

Компілятор сам забезпечує цю межу: Rc<T> не є Send, тож спроба перемістити його в thread::spawn не скомпілюється, підштовхуючи до Arc. Звертання до Arc в межах одного потоку означає лише плату за атомарність, яка ніколи не використовується. Документація Arc докладно описує гарантії впорядкування пам'яті, а Arc<Mutex<T>> лежить в основі значної частини спільного стану в асинхронному Rust із Tokio.

Готовий до співбесід з Rust?

Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.

Box vs Rc vs Arc vs RefCell: коли який використовувати

Ці чотири типи поєднуються за двома осями: скільки власників має значення та чи можна його змінювати через спільний дескриптор. Таблиця підсумовує компроміси.

| Тип | Володіння | Зміна через спільний дескриптор | Потокобезпечний | Накладні витрати | |------|-----------|----------------------------|-------------|----------| | Box<T> | Одне | Ні | Так, якщо T: Send | Лише розміщення в купі | | Rc<T> | Спільне | Ні | Ні | Неатомарний лічильник | | Arc<T> | Спільне | Ні | Так | Атомарний лічильник | | RefCell<T> | Одне | Так (перевірка під час виконання) | Ні | Прапорець позичання під час виконання |

Практичне дерево рішень коротке. Потрібен один власник у купі: Box. Потрібно багато власників в одному потоці: Rc. Потрібно багато власників між потоками: Arc. Потрібно змінювати спільне значення: внутрішній тип загортають у RefCell (один потік) або Mutex (багато потоків). Рекомендації Effective Rust щодо типів посилань і вказівників доходять до тієї самої багатошаровості. Найкраще починати з найменш потужного варіанта й додавати можливості лише тоді, коли цього вимагає компілятор.

Питання зі співбесід про розумні вказівники в Rust

Розумні вказівники — надійна тема співбесід, бо вони перевіряють, чи розуміє кандидат володіння, а не лише синтаксис. Наведені нижче питання трапляються часто, а більше їх зібрано в модулі співбесід про розумні вказівники в Rust.

У чому різниця між Rc та Arc? Обидва забезпечують спільне володіння через підрахунок посилань. Rc використовує неатомарний лічильник і обмежений одним потоком; Arc використовує атомарні операції й може бути спільним між потоками за невелику ціну під час виконання. Компілятор забезпечує цей поділ: Rc не є ні Send, ні Sync, тож не може перетнути межу потоку.

Навіщо поєднувати Rc з RefCell? Rc надає спільне володіння, але лише незмінний доступ. RefCell додає внутрішню змінюваність, дозволяючи власникам змінювати значення через спільне посилання з перевіркою позичання під час виконання. Разом Rc<RefCell<T>> є ідіоматичним однопотоковим будівельним блоком спільного змінюваного стану.

Як підрахунок посилань може спричинити витік пам'яті в Rust? Два значення Rc (або Arc), що посилаються одне на одне, утворюють цикл, чиї сильні лічильники ніколи не досягають нуля, тож виділення ніколи не звільняється. Розрив циклу за допомогою Weak<T>, який тримає посилання без володіння, відновлює правильне очищення.

Коли Box кращий за Rc? Щоразу, коли достатньо одного власника. Box не має накладних витрат на підрахунок посилань, тож це типовий вибір для розміщення в купі, рекурсивних типів і трейт-об'єктів. До Rc варто вдаватися лише тоді, коли потрібне справжнє спільне володіння.

Висновок

Розумні вказівники в Rust перетворюють володіння з обмеження на набір поєднуваних будівельних блоків. Вибір між ними випливає безпосередньо з форми задачі:

  • Box<T> варто обирати, коли значенню потрібен один власник у купі, рекурсивному типу потрібен фіксований розмір або функція повертає трейт-об'єкт.
  • Rc<T> застосовують для кількох власників в одному потоці й переходять на Arc<T> у момент, коли володіння перетинає межу потоку.
  • RefCell<T> додають для внутрішньої змінюваності в однопотоковому коді, а Mutex<T> — для конкурентного еквівалента, тримаючи охоронці позичання й блокування короткоживучими.
  • Поєднувати їх слід свідомо: Rc<RefCell<T>> для спільного змінюваного стану в одному потоці, Arc<Mutex<T>> між потоками.
  • Слід стежити за циклами посилань між лічильними вказівниками й розривати їх за допомогою Weak<T>, щоб уникнути витоків.
  • За замовчуванням варто обирати найменш потужний тип і дозволяти компілятору підказати, коли потрібно більше можливостей.

Починай практикувати!

Перевір свої знання з нашими симуляторами співбесід та технічними тестами.

Теги

#rust
#smart-pointers
#memory-management
#interview
#rust-2024-edition

Поділитися

Пов'язані статті