Interior mutability в Rust
Вопрос уровня middle: как менять значение через общую ссылку, если правило XOR это запрещает. Спрашивают про Cell и RefCell, про разницу проверок компилятором и в рантайме, про то, почему RefCell нельзя в потоки.
Стержень: обёртки вроде Cell, RefCell, Mutex переносят проверку эксклюзивности с этапа компиляции в рантайм, а снаружи остаются обычным &T.
// Формулировки: «что такое interior mutability?», «чем Cell отличается от RefCell?», «почему Arc<RefCell<T>> не собирается?»
Зачем это нужно
Иногда данные логически разделяемые, но изменяемые: счётчик обращений в структуре с кучей владельцев, кэш внутри неизменяемого объекта, граф с обратными ссылками. Правило XOR (исключающее или) такие случаи не выражает, потому что оно консервативно - оно запрещает и то, что на самом деле безопасно.
Внутренняя изменяемость - легальный обход: снаружи ссылка обычная, а обёртка сама следит, чтобы одновременных изменений не случилось. В основе всех таких типов лежит UnsafeCell - единственная точка в языке, где компилятору говорят «здесь можно менять через &».
- interior mutability
- изменение через &T под контролем обёртки
- UnsafeCell
- примитив, на котором построены Cell, RefCell и Mutex
Cell против RefCell
Cell работает значением целиком: get копирует наружу, set заменяет внутри. Ссылок внутрь он не выдаёт, поэтому проверять нечего и накладных расходов нет вовсе. Подходит для Copy-типов - счётчиков, флагов.
RefCell выдаёт настоящие ссылки: borrow() и borrow_mut(). Значит, за правилом XOR надо следить, и он следит счётчиками прямо в рантайме. Второй borrow_mut при живом первом паникует с сообщением already borrowed. Мягкая версия - try_borrow_mut, она возвращает Result.
// Отсюда главная мысль про RefCell: он не отменяет правило, а переносит его нарушение из ошибки сборки в панику в проде. Это компромисс, а не бесплатная гибкость.
let c = RefCell::new(1);
let a = c.borrow_mut();
let b = c.borrow_mut(); // паника- Cell
- подмена значения целиком, без выдачи ссылок
- RefCell
- ссылки внутрь со счётчиками заимствований в рантайме
Однопоточное и многопоточное
RefCell не Sync: его счётчики обычные, без синхронизации. Поэтому Arc<RefCell<T>> не собирается - компилятор выдаёт E0277 и справедливо: два потока могли бы одновременно тронуть счётчик и получить гонку.
Многопоточный аналог - Mutex или RwLock: та же идея внутренней изменяемости, только проверка идёт через блокировку. Схема запоминается парами: Rc<RefCell<T>> в одном потоке, Arc<Mutex<T>> в нескольких.
// Для ленивой глобальной инициализации в стандартной библиотеке теперь есть OnceLock (записать один раз) и LazyLock (замыкание инициализации при первом обращении) - внешний lazy_static больше не нужен.
static CONF: LazyLock<Config> =
LazyLock::new(|| load());- Sync
- тип можно разделять по ссылке между потоками
- LazyLock
- ленивая статическая инициализация из std
Как отвечать: «Чем Cell отличается от RefCell?»
Оба дают внутреннюю изменяемость, но разными способами. Cell работает значением целиком: get копирует наружу, set заменяет внутри, ссылок он не выдаёт, поэтому проверять нечего и накладных расходов нет. Его берут для Copy-типов вроде счётчиков и флагов. RefCell выдаёт настоящие ссылки через borrow и borrow_mut, поэтому вынужден следить за правилом XOR счётчиками в рантайме: второй borrow_mut паникует. То есть RefCell не отменяет правило, а переносит его нарушение из ошибки компиляции в панику, и это стоит помнить, когда выбираешь его ради гибкости.
Ты объясняешь механизм, а не список методов, и честно называешь цену RefCell. Именно этого ждут: понимания компромисса, а не знания API.
На чём валятся
- −Считают, что RefCell снимает правило XOR. Оно лишь проверяется позже, и роняет прод паникой.
- −Пробуют Arc<RefCell<T>> и не могут объяснить ошибку про Sync.
- −Не знают, что у Cell нет накладных расходов, и берут RefCell везде подряд.
- −Держат borrow_mut дольше нужного и ловят панику при повторном заимствовании.
- −Тянут lazy_static, не зная про OnceLock и LazyLock в стандартной библиотеке.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 12, остальные разбираются в тренажёре.
- Что будет при выполнении?
let c = RefCell::new(1); let a = c.borrow_mut(); let b = c.borrow_mut();A)Обе ссылки получены: RefCell снимает правило XORB)Ошибка компиляции: две изменяемые ссылки на одно значениеC)Второй вызов вернёт Err — borrow_mut отдаёт ResultD)Паника в рантайме: RefCell already borrowedпоказать ответ и разбор
+D)Паника в рантайме: RefCell already borrowed// разбор: RefCell переносит проверку XOR из компиляции в рантайм: счётчик заимствований живёт в самом значении. Второй borrow_mut видит активное изменяемое заимствование и паникует. Именно поэтому RefCell — компромисс: гибкость есть, но ошибка проектирования всплывает в проде, а не на сборке. Мягкий вариант — try_borrow_mut с Result.
- Почему RefCell нельзя положить в Arc и отправить в другой поток?A)RefCell не Send: он привязан к потоку, в котором созданB)Arc принимает только типы, реализующие CopyC)RefCell не Sync: его счётчики заимствований не защищены от гонокD)Можно: компилятор пропустит, гонка проявится лишь под нагрузкой
показать ответ и разбор
+C)RefCell не Sync: его счётчики заимствований не защищены от гонок// разбор: Arc<T> становится Send только когда T: Send + Sync — иначе разделяемая ссылка дала бы двум потокам одновременный доступ. У RefCell счётчик заимствований обычный, без синхронизации, поэтому Sync у него нет и сборка падает с E0277. В многопотоке ту же роль играет Mutex или RwLock.
- Нужен глобальный конфиг, который считывается при первом обращении. Что взять в современном Rust?A)LazyLock или OnceLock из стандартной библиотекиB)thread_local! с RefCell внутриC)const-функцию: она посчитается на этапе компиляцииD)static mut с инициализацией в начале main
показать ответ и разбор
+A)LazyLock или OnceLock из стандартной библиотеки// разбор: OnceLock — ячейка, которую можно записать один раз; LazyLock делает то же с замыканием инициализации и синхронизирует первое обращение. Оба в std с 1.70 и 1.80, поэтому внешний lazy_static больше не нужен. static mut требует unsafe, а в редакции 2024 любые ссылки на него стали ошибкой компиляции; thread_local даёт копию на поток, а const считает только то, что известно компилятору.
- Когда RefCell не нужен, хотя кажется, что без него никак?A)Когда значение только читают из нескольких местB)Когда доступ к значению идёт из нескольких потоков одновременноC)Когда до значения можно дотянуться одной изменяемой ссылкойD)Когда значение реализует Copy
показать ответ и разбор
+C)Когда до значения можно дотянуться одной изменяемой ссылкой// разбор: Внутренняя изменяемость нужна там, где по правилам заимствования взять &mut невозможно: разделяемое владение через Rc, метод с &self, значение в глобальной ячейке. Если структура принадлежит одному месту, достаточно &mut self и обычных правил — а RefCell лишь перенесёт проверку в рантайм и добавит шанс паники. Типичный оверинжиниринг новичка.
- Что вернёт try_borrow_mut, если изменяемое заимствование уже активно?A)Err — вызывающий сам решает, что делатьB)Панику, как и borrow_mutC)None и продолжит выполнениеD)Ссылку: try-версия обходит проверку заимствования
показать ответ и разбор
+A)Err — вызывающий сам решает, что делать// разбор: try_borrow и try_borrow_mut — те же проверки, но с результатом вместо паники: Result с BorrowMutError в ветке Err. Их берут там, где конфликт заимствования возможен по устройству программы и это не баг — например, при рекурсивных обходах графа или в обработчике, который может быть вызван повторно. Если конфликт означает ошибку проектирования, оставляют borrow_mut: паника громче.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.