Перейти к основному содержимому
  1. Rust/

Внутренняя мутабельность в Rust: паттерн использования Cell, RefCell

1325 слов·7 минут· loading · loading · · ·Rust-middle
О Rust - Эта статья часть цикла.
Статей прочитано 0/38
0%
📚 Введение и дополнительные материалы
🟢 Начальный уровень (Rust-basic)
Не прочитана
🔵 Средний уровень (Rust-middle)
31 Внутренняя мутабельность в Rust: паттерн использования Cell, RefCell (текущая)
Не прочитана

Введение
#

Обычно borrow checker действует жестко: объявляем переменную без mut — и компилятор ни за что не даст изменять её поля по иммутабельной ссылке &T. Это стандартная унаследованная мутабельность (Inherited Mutability).

Но на практике регулярно возникают ситуации, когда компоненту нужно сохранить закэшированный ответ, записать ошибку в журнал или обновить внутренний идентификатор, имея на руках только неизменяемую ссылку &self.

Для таких случаев в стандартной библиотеке Rust есть паттерн Внутренней мутабельности (Interior Mutability). В этой статье разберем, как устроены Cell и RefCell, где они применяются на практике и чем отличаются от других контейнеров.


1. Контейнер Cell: Мутабельность без ссылок
#

Самый простой инструмент внутренней мутабельности — тип std::cell::Cell<T>.

Особенность Cell<T> в том, что он никогда не выдает прямых ссылок &T или &mut T на то значение, которое лежит внутри него.

Вместо ссылок Cell работает путем цельного чтения, записи или перемещения данных:

  • get() — возвращает копию внутреннего значения (требует, чтобы T: Copy).
  • set(val) — полностью заменяет внутреннее значение на val.
  • replace(val) — помещает val внутрь контейнера и возвращает ранее хранившееся там значение.
  • take() — извлекает значение, оставляя на его месте Default::default().
use std::cell::Cell;

struct Counter {
    count: Cell<u32>,
}

fn main() {
    let c = Counter { count: Cell::new(0) };
    
    // Переменная 'c' НЕ мутабельна! Но мы спокойно меняем count:
    c.count.set(c.count.get() + 1);
    
    println!("Текущий счетчик: {}", c.count.get()); // Выведет 1
}
Примечание

Запомните: если тип T не реализует типаж Copy (например, String или Vec), метод .get() не скомпилируется. Однако со сложными типами без Copy в Cell по-прежнему можно работать через .set(), .replace() и .take().

Совет

Поскольку Cell<T> не выдает ссылок на внутреннее содержимое, ему не нужно отслеживать счётчик заимствований. В нем полностью отсутствуют накладные расходы рантайма (Zero-cost abstraction) и риск паники! Это идеальный выбор для флагов, счетчиков и Copy-типов.


2. Контейнер RefCell: Динамический Borrow Checker
#

Если внутри контейнера лежит сложная структура (например, Vec<String>, HashMap или пользовательский объект), скопировать ее полностью через Cell::get() не получится.

Для сложных типов используется std::cell::RefCell<T>.

RefCell<T> позволяет запрашивать ссылки на внутреннее содержимое:

  • .borrow() — возвращает обертку Ref<T>, работающую как обычная иммутабельная ссылка &T.
  • .borrow_mut() — возвращает обертку RefMut<T>, работающую как мутабельная ссылка &mut T.
  • .try_borrow() / .try_borrow_mut() — безопасные варианты, возвращающие Result. Они позволяют проверить заимствование и избежать вызова паники при конфликте ссылок.

Проверки заимствования в рантайме
#

Обычный компилятор Rust проверяет правило: «или много &, или ровно один &mut» во время компиляции.

RefCell переносит этот счетчик заимствований на этап выполнения программы (runtime).

use std::cell::RefCell;

fn main() {
    let data = RefCell::new(vec![1, 2, 3]);

    let mut b1 = data.borrow_mut();
    b1.push(4);

    // ВНИМАНИЕ: b1 еще активен! Попытка сделать второй borrow_mut вызовет ПАНИКУ:
    // let mut b2 = data.borrow_mut(); // ALREADY BORROWED PANIC!
}

Благодаря идиоме RAII, смарт-указатели Ref и RefMut в своём деструкторе Drop автоматически уменьшают счётчик заимствований RefCell при выходе из области видимости. Поэтому если вы явно вызовите drop(b1), следующий вызов data.borrow_mut() отработает без ошибок.

Предостережение

Помните: RefCell не отменяет правила заимствования Rust, он лишь откладывает их проверку до рантайма. Ошибки проектирования приведут не к ошибке компиляции, а к падению программы!


3. Где это пригодится в реальном коде
#

Теперь, когда мы знаем, как работают Cell и RefCell, разберем, зачем менять данные через неизменяемую ссылку &self, если можно просто прописать &mut self.

В реальном коде сделать &mut self получается не всегда. Вот три частых сценария:

1. Счётчик кликов у кнопки
#

Возьмём обычный UI-компонент кнопки. Попробуем обновить счётчик кликов в методе &self:

Сравнение: Ошибка компиляции vs Решение с Cell
Шаг 1/2
 1// ?compile_fail
 2
 3struct Button {
 4    label: String,
 5    click_count: u32,
 6}
 7
 8impl Button {
 9    fn on_click(&self) {
10        println!("Кнопка '{}' нажата!", self.label);
11        self.click_count += 1;
12    }
13}
14
15fn main() {
16    let btn = Button {
17        label: String::from("Отправить"),
18        click_count: 0,
19    };
20    btn.on_click();
21}
22

Без Cell: Ошибка компиляции

  • Компилятор запрещает изменять поле click_count по неизменяемой ссылке &self.
  • Сделать метод &mut self в UI-компонентах часто нельзя, так как кнопка доступна одновременно из разных частей приложения.

В UI-библиотеках методы обработки событий работают через ссылки &self, так как кнопка вызывается из разных частей приложения. Cell дает возможность менять внутренний счётчик кликов прямо по &self без пробрасывания &mut self по всей цепочке вызовов.

2. Накопление истории логов в сервисе
#

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

struct App {
    name: String,
    // Список логов прямо внутри приложения
    logs: RefCell<Vec<String>>,
}

impl App {
    fn log_error(&self, msg: &str) {
        // Добавляем запись в список, имея только &self
        self.logs.borrow_mut().push(msg.to_string());
    }
}

3. Флаг состояния «уже выполнено»
#

То же самое с флагами инициализации в фоновом плеере или загрузчике:

struct Player {
    is_playing: Cell<bool>,
}

impl Player {
    fn play(&self) {
        if !self.is_playing.get() {
            println!("Включаем воспроизведение...");
            self.is_playing.set(true);
        }
    }
}

4. Ограничение по многопоточности
#

Важный нюанс: контейнеры Cell и RefCell предназначены строго для однопоточного кода.

Оба типа не реализуют маркерный типаж Sync, поэтому передать ссылку на них в другой поток не выйдет — компилятор заблокирует такую попытку еще при сборке.

Подробный разбор многопоточной внутренней мутабельности (Mutex, RwLock), а также типажей Send и Sync выйдет в отдельной статье.


ТипВыдает ссылки?Проверка заимствованияТребования к типам / ТрейтамНакладные расходы
Cell<T>Нет (только get/set)Не требуетсяТребует T: Copy для .get()Нулевые (Zero-cost)
RefCell<T>Да (Ref / RefMut)Runtime (Паника при нарушениях)Работает с любым TНебольшой счетчик в рантайме

Давайте изучим работу этих типов на практических слайдах:

Сравнение работы Cell<T> и RefCell<T>
Шаг 1/2
 1use std::cell::Cell;
 2
 3struct Logger {
 4    log_count: Cell<usize>,
 5}
 6
 7impl Logger {
 8    fn new() -> Self {
 9        Logger {
10            log_count: Cell::new(0),
11        }
12    }
13
14    // Обратите внимание: метод принимает &self (неизменяемую ссылку!), но меняет log_count!
15    fn log(&self, message: &str) {
16        println!("[LOG]: {message}");
17        let current = self.log_count.get();
18        self.log_count.set(current + 1);
19    }
20}
21
22fn main() {
23    let logger = Logger::new();
24
25    logger.log("Первое сообщение системы");
26    logger.log("Второе сообщение системы");
27
28    println!("Всего записей в логе: {}", logger.log_count.get());
29
30    // Метод replace заменяет значение и возвращает старое
31    let old_val = logger.log_count.replace(100);
32    println!("Старый счетчик: {old_val}, новый счетчик: {}", logger.log_count.get());
33}
34

Внутренняя мутабельность с Cell

  • Cell<T> позволяет изменять значение внутри объекта, имея только неизменяемую ссылку &self.
  • Запись set() и чтение get() работают путем копирования или перемещения значения без создания ссылок &T.
  • Идеально подходит для простых типов (Copy) или флагов и счетчиков.

Проверь свои знания!
#

Пройдите короткий интерактивный тест по теме внутренней мутабельности:

Статья прочитана
Пожалуйста, оцените насколько статья была вам полезна и понятна
Цикл статей
О Rust - Эта статья часть цикла.
Статей прочитано 0/38
0%
📚 Введение и дополнительные материалы
🟢 Начальный уровень (Rust-basic)
Не прочитана
🔵 Средний уровень (Rust-middle)
31 Внутренняя мутабельность в Rust: паттерн использования Cell, RefCell (текущая)
Не прочитана

Связанные статьи