Введение#
Обычно 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:
В 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 | Небольшой счетчик в рантайме |
Давайте изучим работу этих типов на практических слайдах:
Проверь свои знания!#
Пройдите короткий интерактивный тест по теме внутренней мутабельности:


