сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Владение и лайфтаймы

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, остальные разбираются в тренажёре.

  1. #rs_interior_mut1 / 5
    Что будет при выполнении?
    let c = RefCell::new(1);
    let a = c.borrow_mut();
    let b = c.borrow_mut();
    A)Обе ссылки получены: RefCell снимает правило XOR
    B)Ошибка компиляции: две изменяемые ссылки на одно значение
    C)Второй вызов вернёт Err — borrow_mut отдаёт Result
    D)Паника в рантайме: RefCell already borrowed
    показать ответ и разбор
    +D)Паника в рантайме: RefCell already borrowed

    // разбор: RefCell переносит проверку XOR из компиляции в рантайм: счётчик заимствований живёт в самом значении. Второй borrow_mut видит активное изменяемое заимствование и паникует. Именно поэтому RefCell — компромисс: гибкость есть, но ошибка проектирования всплывает в проде, а не на сборке. Мягкий вариант — try_borrow_mut с Result.

  2. #rs_interior_mut2 / 5
    Почему RefCell нельзя положить в Arc и отправить в другой поток?
    A)RefCell не Send: он привязан к потоку, в котором создан
    B)Arc принимает только типы, реализующие Copy
    C)RefCell не Sync: его счётчики заимствований не защищены от гонок
    D)Можно: компилятор пропустит, гонка проявится лишь под нагрузкой
    показать ответ и разбор
    +C)RefCell не Sync: его счётчики заимствований не защищены от гонок

    // разбор: Arc<T> становится Send только когда T: Send + Sync — иначе разделяемая ссылка дала бы двум потокам одновременный доступ. У RefCell счётчик заимствований обычный, без синхронизации, поэтому Sync у него нет и сборка падает с E0277. В многопотоке ту же роль играет Mutex или RwLock.

  3. #rs_interior_mut3 / 5
    Нужен глобальный конфиг, который считывается при первом обращении. Что взять в современном 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 считает только то, что известно компилятору.

  4. #rs_interior_mut4 / 5
    Когда RefCell не нужен, хотя кажется, что без него никак?
    A)Когда значение только читают из нескольких мест
    B)Когда доступ к значению идёт из нескольких потоков одновременно
    C)Когда до значения можно дотянуться одной изменяемой ссылкой
    D)Когда значение реализует Copy
    показать ответ и разбор
    +C)Когда до значения можно дотянуться одной изменяемой ссылкой

    // разбор: Внутренняя изменяемость нужна там, где по правилам заимствования взять &mut невозможно: разделяемое владение через Rc, метод с &self, значение в глобальной ячейке. Если структура принадлежит одному месту, достаточно &mut self и обычных правил — а RefCell лишь перенесёт проверку в рантайм и добавит шанс паники. Типичный оверинжиниринг новичка.

  5. #rs_interior_mut5 / 5
    Что вернёт try_borrow_mut, если изменяемое заимствование уже активно?
    A)Err — вызывающий сам решает, что делать
    B)Панику, как и borrow_mut
    C)None и продолжит выполнение
    D)Ссылку: try-версия обходит проверку заимствования
    показать ответ и разбор
    +A)Err — вызывающий сам решает, что делать

    // разбор: try_borrow и try_borrow_mut — те же проверки, но с результатом вместо паники: Result с BorrowMutError в ветке Err. Их берут там, где конфликт заимствования возможен по устройству программы и это не баг — например, при рекурсивных обходах графа или в обработчике, который может быть вызван повторно. Если конфликт означает ошибку проектирования, оставляют borrow_mut: паника громче.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.