Weak и Cow в Rust
На странице Умные указатели ты познакомился с Box, Rc и RefCell. Здесь мы добавим ещё два инструмента, которые решают две вполне реальные проблемы. Weak<T> позволяет указывать на значение, не продлевая ему жизнь. Cow<T> позволяет функции вернуть заимствованные данные, когда это возможно, и делать копию, только когда без неё никак.
Представь, что Rc — это ключ от дома: пока хоть у кого-то есть ключ, дом стоит. Weak больше похож на адрес, записанный на бумажке. По нему можно найти дом, но если все вернули ключи и дома больше нет, адрес просто никуда не ведёт. И никто не платит за дом, о котором все забыли.
Проблема: циклы Rc никогда не очищаются
Заголовок раздела «Проблема: циклы Rc никогда не очищаются»Значение в Rc удаляется, когда его счётчик сильных ссылок (strong count) доходит до 0. Это отлично работает, пока два значения не начинают держать Rc друг на друга. Тогда каждое удерживает другое — навсегда.
Чтобы это увидеть, тип Person ниже реализует Drop: при удалении он печатает сообщение. Следи, какие сообщения появятся:
use std::cell::RefCell;use std::rc::Rc;
struct Person { name: String, friend: RefCell<Option<Rc<Person>>>,}
impl Drop for Person { fn drop(&mut self) { println!("удаляем {}", self.name); }}
fn main() { let alice = Rc::new(Person { name: String::from("Алиса"), friend: RefCell::new(None) }); let bob = Rc::new(Person { name: String::from("Боб"), friend: RefCell::new(None) });
// Алиса указывает на Боба, а Боб — обратно на Алису *alice.friend.borrow_mut() = Some(Rc::clone(&bob)); *bob.friend.borrow_mut() = Some(Rc::clone(&alice));
println!("сильных ссылок на Алису: {}", Rc::strong_count(&alice)); println!("сильных ссылок на Боба: {}", Rc::strong_count(&bob)); println!("конец main");}Вывод:
сильных ссылок на Алису: 2сильных ссылок на Боба: 2конец mainОбрати внимание, чего здесь нет: ни удаляем Алиса, ни удаляем Боб. Когда main заканчивается, переменные alice и bob исчезают, и каждый счётчик падает с 2 до 1. Но до 0 он так и не доходит: каждого человека всё ещё держит поле friend другого. Эта память утекла (memory leak).
Не расстраивайся, если это тебя удивило. Компилятор такое не ловит, и программа не падает. Просто память, которая уже никогда не вернётся. В программе, которая работает долго и всё время строит такие циклы, это накапливается.
Weak<T>: указатель, который не продлевает жизнь
Заголовок раздела «Weak<T>: указатель, который не продлевает жизнь»Решение — сделать одну или обе связи слабыми. Weak получают из Rc вызовом Rc::downgrade. Слабая ссылка не считается владельцем, поэтому не мешает значению удалиться.
Раз значения может уже не быть, использовать Weak напрямую нельзя. Нужно вызвать .upgrade(), который вернёт Option<Rc<T>>: Some, если значение ещё живо, и None, если нет.
use std::cell::RefCell;use std::rc::{Rc, Weak};
struct Person { name: String, friend: RefCell<Weak<Person>>,}
impl Drop for Person { fn drop(&mut self) { println!("удаляем {}", self.name); }}
fn main() { let alice = Rc::new(Person { name: String::from("Алиса"), friend: RefCell::new(Weak::new()) }); let bob = Rc::new(Person { name: String::from("Боб"), friend: RefCell::new(Weak::new()) });
*alice.friend.borrow_mut() = Rc::downgrade(&bob); *bob.friend.borrow_mut() = Rc::downgrade(&alice);
if let Some(friend) = alice.friend.borrow().upgrade() { println!("друг Алисы — {}", friend.name); }
println!("Алиса: strong = {}, weak = {}", Rc::strong_count(&alice), Rc::weak_count(&alice)); println!("конец main");}Вывод:
друг Алисы — БобАлиса: strong = 1, weak = 1конец mainудаляем Бобудаляем АлисаНа этот раз появились обе строки удаляем. У Алисы один сильный владелец (переменная alice) и одна слабая ссылка (от Боба). Когда main заканчивается, счётчик сильных ссылок доходит до 0, и Rust её убирает. Боб удаляется первым, потому что переменные удаляются в порядке, обратном созданию.
Что стоит запомнить:
Weak::new()создаёт пустой слабый указатель, который ни на что не указывает.upgrade()на нём всегда возвращаетNone. Удобное начальное значение.Rc::strong_countсчитает владельцев.Rc::weak_countсчитает слабые ссылки. Когда удалять значение, решает только счётчик сильных ссылок.Rc, который вернулupgrade(), — настоящий временный владелец. Когда он выходит из области видимости, счётчик сильных ссылок снова уменьшается.
Дерево папок: дети во владении, родители — слабые
Заголовок раздела «Дерево папок: дети во владении, родители — слабые»Деревья — классическое место для Weak. Папка владеет своими подпапками, поэтому в children лежат Rc. Подпапке тоже хочется знать своего родителя, но владеть им она не должна. Если бы в обе стороны использовался Rc, снова получился бы цикл. Поэтому parent — это Weak:
use std::cell::RefCell;use std::rc::{Rc, Weak};
struct Folder { name: String, parent: RefCell<Weak<Folder>>, children: RefCell<Vec<Rc<Folder>>>,}
fn new_folder(name: &str) -> Rc<Folder> { Rc::new(Folder { name: name.to_string(), parent: RefCell::new(Weak::new()), children: RefCell::new(Vec::new()), })}
fn add_child(parent: &Rc<Folder>, child: Rc<Folder>) { *child.parent.borrow_mut() = Rc::downgrade(parent); parent.children.borrow_mut().push(child);}
fn full_path(folder: &Folder) -> String { match folder.parent.borrow().upgrade() { Some(parent) => format!("{}/{}", full_path(&parent), folder.name), None => folder.name.clone(), }}
fn main() { let home = new_folder("home"); let photos = new_folder("photos"); let trips = new_folder("trips");
add_child(&home, Rc::clone(&photos)); add_child(&photos, Rc::clone(&trips));
match photos.parent.borrow().upgrade() { Some(parent) => println!("photos лежит в {}", parent.name), None => println!("у photos нет родителя"), }
println!("полный путь: {}", full_path(&trips)); println!("home: strong = {}, weak = {}", Rc::strong_count(&home), Rc::weak_count(&home)); println!("photos: strong = {}, weak = {}", Rc::strong_count(&photos), Rc::weak_count(&photos));}Вывод:
photos лежит в homeполный путь: home/photos/tripshome: strong = 1, weak = 1photos: strong = 2, weak = 1Прочитай счётчики не спеша. У photos два сильных владельца: переменная photos и список children внутри home. И одна слабая ссылка: поле parent у trips. У home только один сильный владелец — её собственная переменная, потому что никто не владеет своим родителем. Это ровно та форма, которая нужна: владение идёт вниз по дереву, а ссылки вверх — слабые.
RefCell здесь нужен потому, что parent задаётся, а в children добавляются элементы уже после того, как папки оказались внутри Rc. А сам по себе Rc даёт только общий доступ на чтение.
Слабые списки, которые ничего не удерживают
Заголовок раздела «Слабые списки, которые ничего не удерживают»Weak отлично подходит и для списков вида «присматривай за ними, но не держи»: слушатели, наблюдатели (observers) или кеш вещей, которыми владеет кто-то другой. Здесь счёт в игре рассылает обновления всем, кто ещё слушает. Когда слушателя удаляют, его слабая ссылка просто перестаёт работать:
use std::rc::{Rc, Weak};
struct Listener { name: String,}
fn notify(listeners: &[Weak<Listener>], event: &str) { for weak in listeners { match weak.upgrade() { Some(listener) => println!("{} получил: {event}", listener.name), None => println!("(слушателя больше нет, пропускаем)"), } }}
fn main() { let screen = Rc::new(Listener { name: String::from("экран") }); let logger = Rc::new(Listener { name: String::from("логгер") });
let mut listeners: Vec<Weak<Listener>> = Vec::new(); listeners.push(Rc::downgrade(&screen)); listeners.push(Rc::downgrade(&logger));
notify(&listeners, "счёт 10");
drop(logger); // владелец закрыл логгер
notify(&listeners, "счёт 20");
// убираем ссылки, которые ни на что не указывают listeners.retain(|weak| weak.upgrade().is_some()); println!("осталось слушателей: {}", listeners.len());}Вывод:
экран получил: счёт 10логгер получил: счёт 10экран получил: счёт 20(слушателя больше нет, пропускаем)осталось слушателей: 1Список ни разу не помешал удалить логгер. Если бы в нём лежали Rc, логгер продолжал бы жить внутри списка и получать сообщения, которые никому не нужны. С Weak когда что-то исчезает, решает владелец, а все остальные просто проверяют это через upgrade().
Cow: заимствуй, когда можно, копируй, когда нужно
Заголовок раздела «Cow: заимствуй, когда можно, копируй, когда нужно»Теперь другая проблема. Допустим, ты пишешь функцию, которая приводит имена пользователей в порядок. Большинство имён уже нормальные. В некоторых есть лишние пробелы или заглавные буквы. Если функция всегда возвращает новый String, ты копируешь каждое имя — даже те, что не менял.
Эту задачу решает Cow («clone on write», «клонировать при записи», из std::borrow::Cow). Cow<'_, str> — это перечисление с двумя вариантами:
Cow::Borrowed(&str): «вот исходный текст, нетронутый». Без выделения памяти.Cow::Owned(String): «пришлось его изменить, так что вот новыйString».
Имена во входных данных ниже остаются на английском: пример меняет регистр букв, а такие строки на этом сайте мы не переводим.
use std::borrow::Cow;
fn normalize(name: &str) -> Cow<'_, str> { let trimmed = name.trim(); let has_upper = name.chars().any(|c| c.is_uppercase());
if trimmed.len() == name.len() && !has_upper { Cow::Borrowed(name) // уже чистое: возвращаем оригинал } else { Cow::Owned(trimmed.to_lowercase()) // нужно исправить: собираем новый String }}
fn main() { for input in ["alice", " Bob ", "CAROL", "dave"] { match normalize(input) { Cow::Borrowed(name) => println!("{input:?} -> {name:?} (заимствовано, без копии)"), Cow::Owned(name) => println!("{input:?} -> {name:?} (во владении, новый String)"), } }}Вывод:
"alice" -> "alice" (заимствовано, без копии)" Bob " -> "bob" (во владении, новый String)"CAROL" -> "carol" (во владении, новый String)"dave" -> "dave" (заимствовано, без копии)Два имени из четырёх не стоили вообще ничего. В реальной программе, где 99% входных данных уже чистые, это очень много копий, которые ты так и не сделал. '_ в Cow<'_, str> просто говорит: «это может заимствовать из входных данных». Если времена жизни пока кажутся туманными, загляни на страницу Времена жизни.
Cow как обычная строка
Заголовок раздела «Cow как обычная строка»Чаще всего делать match по Cow вообще не нужно. Он разыменовывается (deref) в &str, так что на нём можно вызывать методы строк и передавать &name в любую функцию, которая принимает &str. Когда тебе действительно нужен собственный String, вызови .into_owned(). Он вынимает String, если тот там есть, и копирует, только если Cow был заимствованным.
use std::borrow::Cow;
fn normalize(name: &str) -> Cow<'_, str> { let trimmed = name.trim(); if trimmed.len() == name.len() && !name.chars().any(|c| c.is_uppercase()) { Cow::Borrowed(name) } else { Cow::Owned(trimmed.to_lowercase()) }}
fn greet(name: &str) { println!("Добро пожаловать, {name}!");}
fn main() { let name = normalize(" Eve ");
println!("длина: {}", name.len()); // методы str работают напрямую println!("начинается с e: {}", name.starts_with('e')); greet(&name); // &Cow<str> превращается в &str
let saved: String = name.into_owned(); // теперь это обычный String println!("сохранили {saved:?} в список пользователей");}Вывод:
длина: 3начинается с e: trueДобро пожаловать, eve!сохранили "eve" в список пользователейCow работает и с другими типами, например Cow<'_, [i32]> для среза, который, возможно, придётся менять. Но для текста чаще всего встречается именно Cow<'_, str>.
Что когда использовать
Заголовок раздела «Что когда использовать»Вот короткая шпаргалка по всем «указателеподобным» типам, которые ты уже встречал:
| Тип | Что он даёт | Когда за него браться |
|---|---|---|
Box<T> |
Один владелец, значение в куче | Рекурсивные типы или большое значение, которое хочется дёшево перемещать |
Rc<T> |
Много владельцев, один поток | Несколько частей программы делят одни и те же данные |
Arc<T> |
Много владельцев, несколько потоков | То же, что Rc, но между потоками (см. Общее состояние) |
Weak<T> |
Ссылка, которая не владеет | Ссылки на родителя, списки наблюдателей, кеши, разрыв циклов Rc |
RefCell<T> |
Изменение через общую ссылку с проверкой во время выполнения | Нужно поменять что-то, что лежит внутри Rc |
Cow<'_, T> |
Заимствованное или собственное, решается во время выполнения | Функция, которая обычно возвращает входные данные без изменений |
Если сомневаешься, начни с обычного владения и заимствования. Добавляй эти типы, только когда компилятор или утечка памяти подскажут, что они нужны.
Частые ошибки
Заголовок раздела «Частые ошибки»unwrap() у upgrade() после того, как значение исчезло
Заголовок раздела «unwrap() у upgrade() после того, как значение исчезло»upgrade() возвращает Option не просто так. Если по привычке вызвать .unwrap(), программа запаникует в тот момент, когда значение уже удалено:
use std::rc::Rc;
fn main() { let session = Rc::new(String::from("user-42")); let link = Rc::downgrade(&session);
drop(session); // сессия закончилась
let current = link.upgrade().unwrap(); println!("всё ещё в системе как {current}");}При запуске получишь:
thread 'main' (12345) panicked at src/main.rs:9:34:called `Option::unwrap()` on a `None` valuenote: run with `RUST_BACKTRACE=1` environment variable to display a backtraceСообщение паники Rust печатает по-английски: на строке 9 был вызван unwrap() на значении None. Исправление — считать «значения больше нет» обычным случаем и обрабатывать его через match или if let:
match link.upgrade() { Some(current) => println!("всё ещё в системе как {current}"), None => println!("сессия закончилась"),}Rc для обратной ссылки
Заголовок раздела «Rc для обратной ссылки»Если ребёнок держит Rc<Parent>, а родитель держит Rc<Child>, ты построил тот самый цикл из первого раздела. Ничего не падает и ничто не предупреждает — память просто никогда не возвращается. Хорошее правило: реши, кто кем владеет, используй Rc в этом направлении и Weak для каждой ссылки в обратную сторону.
struct Folder { parent: RefCell<Rc<Folder>>, // неправильно: ребёнок удерживает родителя children: RefCell<Vec<Rc<Folder>>>,}Замени parent на RefCell<Weak<Folder>>, и цикла больше нет.
Практика
Заголовок раздела «Практика»1. Комментарии к удалённому посту
Заголовок раздела «1. Комментарии к удалённому посту»Создай Post с полем title и храни его в Rc. Создай Comment с полями text и post: Weak<Post>. Напиши функцию describe(comment: &Comment), которая печатает "<text>" — комментарий к «<title>», если пост ещё существует, или "<text>" — комментарий к удалённому посту, если нет. Вызови её, удали пост и вызови ещё раз. Подсказка: используй match comment.post.upgrade().
Показать решение
use std::rc::{Rc, Weak};
struct Post { title: String,}
struct Comment { text: String, post: Weak<Post>,}
fn describe(comment: &Comment) { match comment.post.upgrade() { Some(post) => println!("\"{}\" — комментарий к «{}»", comment.text, post.title), None => println!("\"{}\" — комментарий к удалённому посту", comment.text), }}
fn main() { let post = Rc::new(Post { title: String::from("Изучаем Rust") }); let comment = Comment { text: String::from("Отличный пост!"), post: Rc::downgrade(&post) };
describe(&comment); drop(post); describe(&comment);}Вывод:
"Отличный пост!" — комментарий к «Изучаем Rust»"Отличный пост!" — комментарий к удалённому постуКомментарий никогда не продлевает жизнь посту. Как только единственный Rc удалён, upgrade() возвращает None, и комментарий спокойно это обрабатывает, вместо того чтобы паниковать.
2. Экранируем текст, только когда нужно
Заголовок раздела «2. Экранируем текст, только когда нужно»Напиши fn escape(text: &str) -> Cow<'_, str>, которая заменяет каждый < на <. Если в тексте нет <, верни его заимствованным. Проверь функцию на "привет, мир" и "1 < 2" и выведи, какой вариант получился. Подсказка: сначала проверь через text.contains('<'), а для варианта во владении используй text.replace('<', "<").
Показать решение
use std::borrow::Cow;
fn escape(text: &str) -> Cow<'_, str> { if text.contains('<') { Cow::Owned(text.replace('<', "<")) } else { Cow::Borrowed(text) }}
fn main() { for text in ["привет, мир", "1 < 2"] { match escape(text) { Cow::Borrowed(s) => println!("{s} (заимствовано)"), Cow::Owned(s) => println!("{s} (во владении)"), } }}Вывод:
привет, мир (заимствовано)1 < 2 (во владении)За новый String платит только тот текст, в котором действительно есть <. Всё остальное проходит насквозь, нетронутым.
- Два значения
Rc, указывающие друг на друга, никогда не доводят счётчик сильных ссылок до 0, поэтому утекают, и ихDropне запускается. Rc::downgradeсоздаётWeak<T>, который не продлевает жизнь значению.upgrade()возвращаетOption<Rc<T>>.- В деревьях дети хранятся через
Rc, а ссылки на родителя — черезWeak. Владение идёт вниз, слабые ссылки смотрят вверх. - Списки наблюдателей и кеши могут хранить
Weak, чтобы случайно ничего не удерживать. Cow<'_, str>возвращает заимствованный текст, если ничего не изменилось, и собственныйString, только если изменилось. Он разыменовывается в&str, а.into_owned()даётString.
Проверь себя
Заголовок раздела «Проверь себя»Проверь себя
1. Два значения держат Rc друг на друга. Что произойдёт, когда закончится main?
2. Что возвращает weak.upgrade()?
3. Какая связь в дереве папок должна быть Weak?
4. Когда функция, возвращающая Cow<'_, str>, обходится без нового String?
Результат: 0 / 4
Что почитать
Заголовок раздела «Что почитать»- Ссылочные циклы могут привести к утечке памяти — книга «Язык программирования Rust» (перевод на русский): официальный разбор циклов
Rcи дерева со слабыми ссылками на родителя. std::rc::Weak— документация стандартной библиотеки (на английском): все методыWeak, включаяupgrade,strong_countиWeak::new.std::rc— документация стандартной библиотеки (на английском): как сильные и слабые счётчики работают вместе, с длинным примером разрыва циклов.std::borrow::Cow— документация стандартной библиотеки (на английском): перечислениеCow,to_mut,into_ownedи примеры не только со строками.