Раздел 23 · Rust

Умные указатели и внутренняя мутабельность

middle-senior~50 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Умные указатели и внутренняя мутабельность

Прошлый урок закрыл машинную половину блока: ты научился класть данные в байты сам. Сегодня открываем блок «Глубокий core» и идём в другую сторону, к абстракциям над памятью. Один владелец это удобно ровно до того момента, когда значение должно жить дольше одного хозяина или меняться через общую ссылку. Разберём четыре инструмента, которыми Rust решает это, не отдавая управление сборщику мусора: Box, Rc, Cell и RefCell, плюс Weak и Cow.

Идея

Обычная ссылка &T это голый указатель: адрес, и ничего больше. Она ничем не владеет и не знает, когда освобождать память. Умный указатель это другое: тип, который ведёт себя как указатель, но вдобавок владеет тем, на что показывает, и сам прибирает за собой через Drop. Ты уже пользовался такими: String и Vec это умные указатели на буфер в куче.

В этом уроке четыре новых инструмента, и у каждого своя роль. Держи в голове две оси выбора.

Первая ось: сколько владельцев. Один владелец это Box. Много владельцев это Rc (один поток) или Arc (несколько потоков, про него отдельный урок про разделяемое состояние в блоке про многопоточность).

Вторая ось: можно ли менять через общую ссылку. Обычно нет: &T даёт только чтение. Но Cell и RefCell пробивают в этом правиле управляемую дыру, и называется она внутренняя мутабельность.

ИнструментВладельцевМеняет через &selfЦена
Box<T>одиннет (нужен &mut)одна аллокация
Rc<T>много, один потокнеталлокация плюс два счётчика
Cell<T>по местуда, только для Copyноль накладных
RefCell<T>по местуда, любой типфлаг и проверка в рантайме

Box: владение в куче

Box<T> это самый простой умный указатель: он кладёт значение в кучу и владеет им единолично. Указатель лежит на стеке и занимает одно машинное слово, а само значение в куче. Когда Box выходит из области видимости, он освобождает и то, и другое.

Зачем вообще уносить значение в кучу, если можно держать на стеке? Главная причина это рекурсивные типы. Классика это список из пары «голова и хвост»:

#[derive(Debug)]
enum List {
    Cons(i32, List), // так не скомпилируется
    Nil,
}

Компилятор честно отказывается, и его ошибка стоит того, чтобы её прочитать:

error[E0072]: recursive type `List` has infinite size
  |
  |     Cons(i32, List),
  |               ---- recursive without indirection
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`)

Логика прямая: чтобы разложить List по байтам, компилятору нужен его размер. Размер Cons это размер i32 плюс размер List, а размер List снова содержит Cons, и так до бесконечности. Нужна косвенность: если хвост это не сам List, а Box<List>, то узел занимает размер i32 плюс размер указателя, и это конечное число.

#[derive(Debug)]
enum List {
    Cons(i32, Box<List>),
    Nil,
}
use List::{Cons, Nil};

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
    println!("{list:?}"); // Cons(1, Cons(2, Cons(3, Nil)))
}

Второе применение Box это Box<dyn Trait>: трейт-объект не имеет известного размера, поэтому его держат за указателем (об этом был урок про диспетчеризацию). Третье это когда значение большое и его дорого двигать по стеку при каждом move: Box оставляет на стеке только указатель.

Deref и Drop делают указатель умным

Что вообще превращает обычную обёртку в указатель, через который можно работать с данными напрямую? Два трейта.

Deref отвечает на вопрос «куда показывает эта штука». Реализуешь его, и компилятор начинает применять deref coercion: подставляет &MyBox<String> туда, где ждут &str, разыменовывая столько раз, сколько нужно.

use std::ops::Deref;

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

impl<T> Deref for MyBox<T> {
    type Target = T;
    fn deref(&self) -> &T {
        &self.0
    }
}

fn hello(name: &str) {
    println!("привет, {name}");
}

fn main() {
    let b = MyBox::new(String::from("Rust"));
    hello(&b); // &MyBox<String> -> &String -> &str, две ступени deref
    println!("длина {}", b.len()); // b.len() это (*b).len()
}

Без deref coercion пришлось бы писать hello(&(*b)[..]). С ним компилятор сам строит цепочку приведений на этапе компиляции, и она ничего не стоит в рантайме.

Второй трейт это Drop: он отвечает на вопрос «что сделать, когда значение умирает». Это и есть RAII: ресурс живёт ровно столько, сколько значение, которое им владеет. Деструкторы вызываются автоматически и в порядке, обратном созданию.

struct Guard(&'static str);

impl Drop for Guard {
    fn drop(&mut self) {
        println!("освобождаю {}", self.0);
    }
}

fn main() {
    let _first = Guard("первый");
    let _second = Guard("второй");
    println!("работаем");
} // печатает: освобождаю второй, потом освобождаю первый

Вручную звать drop через obj.drop() нельзя, компилятор это запретит: иначе деструктор вызвался бы дважды. Если надо освободить раньше срока, есть функция std::mem::drop(obj), она просто забирает значение по владению и роняет его на месте.

Rc: разделяемое владение

Box хорош, пока владелец один. Но бывает, что у значения честно несколько хозяев: узел графа, на который ссылаются из двух мест, общий кэш, элемент в нескольких списках. Кому из них освобождать память? Ответ: тому, кто уйдёт последним. Это и есть подсчёт ссылок.

Rc<T> (от reference counted) хранит рядом с данными счётчик владельцев. Rc::clone не копирует данные, он копирует указатель и увеличивает счётчик на единицу. Когда Rc дропается, счётчик уменьшается. Дошёл до нуля, значит последний владелец ушёл, и тогда освобождаются и данные, и счётчик.

use std::rc::Rc;

fn main() {
    let a = Rc::new(vec![10, 20, 30]);
    println!("после a: strong = {}", Rc::strong_count(&a)); // 1

    let b = Rc::clone(&a);
    println!("после b: strong = {}", Rc::strong_count(&a)); // 2

    {
        let c = Rc::clone(&a);
        println!("в блоке: strong = {}", Rc::strong_count(&c)); // 3
    } // c дропнут, счётчик снова 2

    println!("после блока: strong = {}", Rc::strong_count(&a)); // 2
    println!("b видит те же данные: {b:?}");
}

Rc::clone(&a) пишут именно так, а не a.clone(), и это не каприз: вызов через Rc::clone сразу говорит читателю «здесь дешёвая операция, инкремент счётчика», а не «здесь глубокое копирование вектора». Договорённость, но полезная.

Два важных ограничения. Первое: Rc живёт только в одном потоке. Его счётчик это обычное число, инкремент которого не атомарен, и два потока легко его испортят гонкой. Компилятор это знает: Rc не реализует Send, и попытка отправить его в другой поток не скомпилируется. Для нескольких потоков есть Arc с атомарным счётчиком, и платишь ты за это атомарными операциями на каждом клоне (подробно в уроке про разделяемое состояние, когда дойдём до многопоточности).

Второе: Rc даёт только чтение. Rc<T> разыменовывается в &T, не в &mut T. И это логично: владельцев несколько, дай одному &mut, и он получит эксклюзивный доступ к тому, на что смотрят остальные. Чтобы менять значение под Rc, нужна внутренняя мутабельность. К ней и переходим.

Покрути сценарии на счётчике. Первая вкладка это нормальная жизнь клонов, к остальным двум вернёмся, когда дойдём до циклов и Weak.

Внутренняя мутабельность: дыра в правилах, прорубленная честно

Вспомни главное правило заёма из урока про ссылки: в любой момент у данных либо сколько угодно общих ссылок &T, либо ровно одна изменяющая &mut T. Компилятор проверяет это статически и не пропускает нарушение.

Внутренняя мутабельность это легальный способ поменять данные, имея на руках только &T. Звучит как обход правил, и формально так и есть, но дыра прорублена аккуратно: правило одного писателя не отменяется, оно лишь переезжает с этапа компиляции на этап выполнения или прячется за безопасный интерфейс.

Фундамент у всего этого один: UnsafeCell<T>. Это единственное место во всём языке, где компилятору разрешено превратить & в &mut. Напрямую с ним работают редко, поверх него уже построены Cell и RefCell, на них и смотрим.

Cell: для маленьких Copy-значений

Cell<T> это самый дешёвый вариант. Он не раздаёт ссылки внутрь, он работает только целыми значениями: get достаёт копию, set кладёт новое, replace меняет и возвращает старое. Раз ссылок наружу нет, нарушить правило заёма невозможно в принципе, и поэтому проверка не нужна вообще. Из-за этого get требует, чтобы T был Copy.

use std::cell::Cell;

struct Counter {
    hits: Cell<u32>,
}

impl Counter {
    // обрати внимание: &self, не &mut self. Метод меняет поле через общую ссылку
    fn hit(&self) {
        self.hits.set(self.hits.get() + 1);
    }
}

fn main() {
    let c = Counter { hits: Cell::new(0) };
    c.hit();
    c.hit();
    println!("hits = {}", c.hits.get()); // 2
}

Cell идеален для счётчиков, флагов, маленьких чисел внутри структуры, которую снаружи хочется держать неизменяемой. Накладных расходов ноль: в памяти Cell<u32> это ровно u32.

RefCell: borrow checker, переехавший в рантайм

Cell не годится, когда значение не Copy или когда нужна именно ссылка внутрь (позвать метод, изменить элемент Vec на месте). Тут выходит RefCell<T>. Он раздаёт ссылки, но следит за правилом заёма сам, во время выполнения.

borrow() возвращает Ref<T> (ведёт себя как &T), borrow_mut() возвращает RefMut<T> (ведёт себя как &mut T). Внутри RefCell держит маленький счётчик активных заёмов. Просишь borrow_mut, когда уже есть хоть один живой заём, и RefCell не молчит, а паникует: already borrowed: BorrowMutError.

use std::cell::RefCell;

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

    cell.borrow_mut().push(4); // взяли &mut, поменяли, тут же отпустили
    println!("len = {}", cell.borrow().len()); // 4

    let r = cell.borrow(); // живой читатель
    let w = cell.borrow_mut(); // паника: already borrowed: BorrowMutError
    println!("{} {:?}", r.len(), w);
}

Та же ошибка, которую Box или обычная ссылка поймали бы при компиляции, здесь превращается в панику в рантайме. Это и есть цена гибкости: ты получил право менять через &self, но обязан сам не нарушать правило, иначе программа упадёт. Хорошая новость в том, что падение это паника, а не тихая порча данных или гонка: баг виден сразу.

Прощёлкай сценарии. Обрати внимание, как меняется состояние ячейки и в какой момент borrow_mut упирается в живой Ref.

Главный практический вывод из виджета: держи Ref и RefMut как можно короче. Самая частая BorrowMutError это случайно сохранённый в переменную borrow(), который не успел дропнуться к следующему borrow_mut(). Лечится либо отдельным блоком { ... }, либо тем, чтобы не давать заёму имени и пользоваться им прямо в выражении.

Rc плюс RefCell: рабочая лошадка

По отдельности Rc и RefCell решают половину задачи: Rc даёт много владельцев, но только чтение, RefCell даёт изменение через &self, но одного владельца. Сложи их, и получишь разделяемое изменяемое состояние в одном потоке, Rc<RefCell<T>>. Это самая частая комбинация во всём блоке.

use std::cell::RefCell;
use std::rc::Rc;

type Shared = Rc<RefCell<i64>>;

fn bump(s: &Shared) {
    *s.borrow_mut() += 1;
}

fn main() {
    let shared: Shared = Rc::new(RefCell::new(0));
    let clone = Rc::clone(&shared); // второй владелец того же счётчика

    bump(&shared);
    bump(&clone); // двигаем через другой клон

    println!("общий счётчик = {}", shared.borrow()); // 2
}

Читается слой за слоем изнутри наружу: i64 это значение, RefCell даёт менять его через общую ссылку, Rc даёт держать на него несколько владельцев. В следующем уроке про связный список тремя способами ты соберёшь на этой паре целую структуру данных и заодно увидишь, где она удобна, а где честнее взять Box.

Weak: чем разрывают циклы

У подсчёта ссылок есть классическая ловушка, и она серьёзная. Если два значения держат Rc друг на друга, их счётчики никогда не дойдут до нуля, даже когда снаружи на них уже никто не смотрит. Память утечёт. Сборщика мусора, который умеет находить такие циклы, в Rust нет, и Rc тут бессилен.

use std::cell::RefCell;
use std::rc::Rc;

struct Cat {
    name: String,
    friend: RefCell<Option<Rc<Cat>>>,
}

impl Drop for Cat {
    fn drop(&mut self) {
        println!("прощай, {}", self.name);
    }
}

fn main() {
    let tom = Rc::new(Cat { name: "Том".into(), friend: RefCell::new(None) });
    let jerry = Rc::new(Cat { name: "Джерри".into(), friend: RefCell::new(None) });

    *tom.friend.borrow_mut() = Some(Rc::clone(&jerry)); // strong Джерри = 2
    *jerry.friend.borrow_mut() = Some(Rc::clone(&tom)); // strong Тома = 2

    println!("strong Тома = {}", Rc::strong_count(&tom)); // 2
} // tom и jerry уходят, счётчики падают до 1, но не до нуля

Этот код не напечатает ни одного «прощай»: деструкторы не вызовутся, потому что после выхода из main у каждого кота остаётся одна ссылка от другого. Кольцо владения замкнулось, и память зависла навсегда. Открой виджет выше, вкладку «цикл течёт», там это видно по счётчикам, которые упёрлись в единицу.

Лекарство это Weak<T>. Это ссылка, которая не владеет значением: она не входит в счётчик strong и потому не мешает освобождению. У Rc на самом деле два счётчика, strong и weak, и память под значением живёт, пока strong больше нуля. Чтобы добраться до данных через Weak, его надо upgrade(): вернётся Some(Rc), если значение ещё живо, и None, если уже освобождено. Повисшего указателя не бывает по определению.

Правило для деревьев и графов простое: вниз по иерархии владеют через Rc, обратно вверх ссылаются через Weak. Тогда у владения нет колец.

use std::cell::RefCell;
use std::rc::{Rc, Weak};

struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,      // вверх: слабая ссылка
    children: RefCell<Vec<Rc<Node>>>, // вниз: сильное владение
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch); // downgrade двигает weak, не strong

    println!(
        "branch: strong = {}, weak = {}",
        Rc::strong_count(&branch), // 1
        Rc::weak_count(&branch),   // 1
    );

    // тянемся к родителю безопасно
    if let Some(parent) = leaf.parent.borrow().upgrade() {
        println!("родитель листа: {}", parent.value); // 5
    }
} // оба узла освобождаются: колец владения нет

В виджете это вкладка «Weak разрывает цикл»: downgrade поднимает не strong, а weak, кольца владения нет, и в конце оба счётчика честно доходят до нуля.

Cow: заём, пока можно, копия, когда придётся

Последний инструмент стоит особняком, но логически сюда же. Cow<T> (clone on write) решает частую задачу: функция обычно только читает чужие данные и хочет вернуть их как есть без копии, но иногда вынуждена что-то поменять, и вот тогда копия нужна. Платить за копию всегда жалко, не платить нельзя.

Cow это enum: Borrowed держит заём, Owned держит собственную копию. Пока ты только читаешь, живёт дешёвый Borrowed. Понадобилось изменить, и to_mut делает копию ровно один раз.

use std::borrow::Cow;

// приводим к верхнему регистру, но копируем строку, только если в ней есть что менять
fn ensure_caps(input: &str) -> Cow<'_, str> {
    if input.chars().any(|c| c.is_lowercase()) {
        Cow::Owned(input.to_uppercase()) // пришлось скопировать
    } else {
        Cow::Borrowed(input) // уже в верхнем регистре, отдаём заём без аллокации
    }
}

fn main() {
    println!("{}", ensure_caps("rust")); // RUST, была аллокация
    println!("{}", ensure_caps("RUST")); // RUST, ни одной аллокации
}

Cow реализует Deref, поэтому читать данные через него можно как через обычный &str, не разбираясь, что внутри. Рядом стоят Rc::make_mut и Arc::make_mut: они дают то же поведение для разделяемых данных, копируя значение, только если на него смотрит больше одного владельца.

Цена: за что ты платишь

Ни одна из этих абстракций не бесплатна целиком, и инженерное решение это всегда выбор по цене.

ИнструментПамятьОперацияКогда мимо
Box<T>одно слово плюс кучаDeref бесплатензначение мелкое и живёт на стеке
Rc<T>данные плюс два счётчикаclone это инкремент, не атомарныйвладелец один: бери Box
Arc<T>данные плюс два атомарных счётчикаclone это атомарный инкремент, дорожеодин поток: хватит Rc
Cell<T>как у Tget/set это копия, бесплатнотип не Copy или нужна ссылка внутрь
RefCell<T>T плюс флаг заёмовпроверка на каждом borrow, риск паникипроверки хватает статической: бери &mut

Главное правило выбора звучит так: бери самый слабый инструмент, которого хватает. Нужен один владелец, бери Box, а лучше вообще значение на стеке. Появилось несколько владельцев в одном потоке, добавляй Rc. Понадобилось менять под общей ссылкой, оборачивай в Cell для мелочи или в RefCell для остального. Перешёл в многопоточность, меняй Rc на Arc, а RefCell на Mutex. Каждый шаг по этой лесенке добавляет цену, поэтому поднимайся ровно на ступеньку, не выше.

И ещё одно: иногда самый честный ответ это просто Clone или Copy. Подсчёт ссылок не бесплатен, а маленькую структуру скопировать часто дешевле, чем городить Rc<RefCell<...>> ради экономии одной копии. Rc оправдан, когда данные большие или владельцев действительно много, а не когда тебе лень разобраться с заёмом.

Практика

Соберём на разделяемом изменяемом состоянии маленький счётчик подписчиков. Тип владеет данными через Rc, а меняет их через RefCell, и все его клоны двигают один и тот же счётчик. Это та самая пара Rc<RefCell<T>>, ради которой и затевался урок.

ДЗ

Дальше

Ты собрал ящик с инструментами для работы с памятью поверх голого владения: Box для единственного хозяина, Rc и Weak для разделённого, Cell и RefCell для изменения через общую ссылку, Cow для ленивой копии. В следующем уроке мы возьмём знаменитый учебник «слишком много списков» и построим связный список тремя способами: безопасный на Box, разделяемый на Rc<RefCell> и unsafe на сырых указателях. Это лучший способ почувствовать на руках, где какой инструмент уместен и о чём именно с тобой спорит borrow checker.