CardDOM на Rust: через лайфтаймы и боль
Это четвертая статья в серии про DOM-подобные модели данных в различных языках программирования.
В прошлых сериях:
DOM-подобные структуры данных: что такое, почему они присутствуют везде, как узнать, насколько хорошо их поддерживает ваш язык программирования (бенчмарк CardDOM)
Сегодня мы рассмотрим реализацию Card DOM задачи на языке Rust.
Исходная задача
Краткий повтор, чтобы не ходить по ссылкам:
Для сравнения языков программирования в задачах на обработку иерархий объектов предлагается тестовое задание — упрощенный "редактор карточек".
Документ редактора содержит несколько карточек, каждая из которых хранит элементы: текстовые блоки со стилями, изображения, кнопки, коннекторы и группы (вложенные контейнеры).
Редактору требуется: копирование, удаление, редактирование документов, карточек и их элементов; изменение стилей и работа с перекрестными ссылками (напирмер, кнопки ссылаются на карточки, а коннекторы — на элементы карточек). Все операции обязаны выполняться корректно и безопасно, без сбоев и падений при удалении объектов.
Документы и элементы изменяемы, тогда как стили и битмапы — неизменяемые и общие для разных элементов и модифицируются по принципу copy-on-write.
Удаление любого объекта должно автоматически обрывать связанные ссылки, а попытки обращения к ним — контролироваться без крашей.
При копировании карточек и элементов необходимо сохранять структуру связей, чтобы копии ссылались на копии, а не на оригиналы.
В иерархии не должно быть закольцовок. Группы не могут содержать сами себя или свои подгруппы, а каждый элемент карточки должен иметь одного владельца — карточку или группу.
Все остальные подробности — в оригинальной статье.
Реализация на Rust
Полный исходный код (330 строк) доступен в Rust Playground.
Детали реализации:
Rc
для композиции. Как и в случае сunique_ptr
в C++ мы не можем использоватьBox
для храненияDomNode
поскольку на них могут ссылаться перекрестные ссылки. Дерево изменяемых объектов будет определятьсяRc>
.Weak
для перекрестных связей. Кнопки и коннекторы хранятWeak>
на целевые элементы или карточки, предотвращая циклы.Rc
для разделяемых ресурсов. Стили и битмапы шарятся между элементами карточек черезRc
, обеспечивая общее владение и неизменяемость данных.
Глубокое копирование. Выполняется вручную в два прохода через
DeepCopyContext
иHashMap
, чтобы корректно восстановить перекрестные ссылки.Проверки во время выполнения. Несмотря на заявление о строгом учете владения в языке, оно не работает для единственного указателя, пригодного для DOM-иерархий (
Rc
). Так что Мультипарентинг и циклы в графе владения предотвращаются через ручные проверки, с возвратом ошибок (Error::MultiParenting
,Error::Loop
).Полиморфизм без наследования. Тема была подробно обсуждена в этой статье: Rust и приведение типов. Там же в комментариях был дан совет использовать enum, что делает архитектуру простой, хоть это и создает множество других проблем.
Примеры использования
Создание иерархии объектов
let doc = Document::new();
{
let style = Style::new("Times".to_string(), 16.5, 600);
let card = Card::new();
let hello = CardItem::new_text("Hello".to_string(), style.clone());
let button = CardItem::new_button("Click me".to_string(), Rc::downgrade(&card));
let connector = CardItem::new_connector(
Rc::downgrade(&hello), Rc::downgrade(&button));
assert!(card.borrow_mut().add_item(hello).is_ok());
assert!(card.borrow_mut().add_item(button).is_ok());
assert!(card.borrow_mut().add_item(connector).is_ok());
assert!(doc.borrow_mut().add_card(card).is_ok());
}
Из-за того, что проверки древовидности структуры выполняются в рантайме, конструирования должны сопровождаться ассертами. Кстати добавляет динамический проверок и тот факт, что наши элементы карточек не самостоятельные типы проверяемые при компиляции, а enum tags, требующие рантайм проверки на UnsupportedOperation
.
Изменение стиля через копирование
Поскольку стили и битмапы — неизменяемые ресурсы, разделяемые между множеством текстовых блоков и картинок, любое их изменение должно выполняться через copy-on-write.
{
let hello_item = doc.borrow().cards[0].borrow().items[0].clone();
let hello_borrow = hello_item.borrow();
if let CardItemKind::Text { style, .. } = &hello_borrow.kind {
let new_style = style.clone_resized(style.size + 1.0);
drop(hello_borrow); // Release immutable borrow
assert!(hello_item.borrow_mut().set_style(new_style).is_ok());
}
}
Обратите внимание на ручное управление временем заимствования drop(hello_borrow)
если этого не сделать, программа упадет. Альтернатива - с блоком-инициализатором локальной переменной - еще менее читаемая:
{
let hello_item = doc.borrow().cards[0].borrow().items[0].clone();
let new_style = {
let hello_borrow = hello_item.borrow();
if let CardItemKind::Text { style, .. } = &hello_borrow.kind {
style.clone_resized(style.size + 1.0)
} else {
return; // don't ask me where to
}
};
assert!(hello_item.borrow_mut().set_style(new_style).is_ok());
}
Защита времени жизни
RefCell
паникует при нарушении правил заимствования и при удалении объекта с активным borrow.
Таким образом, Rust предотвращает удаление объектов, методы которых всё ещё на стеке, не продлевая их жизнь, как в языках со сборкой мусора или как shared_ptr
в C++, а в соответствии с Бусидо, через харакири. Программист должен сам организовать владение и синхронизацию вокруг этих ограничений. Таким образом, следующее требование невыполнимо: "Удаление любых объектов из иерархии не должно вызывать сбоев".
Автоматический обрыв перекрестных ссылок при удалении объекта
{
// Remove item and check weak references
let card = &doc.borrow().cards[0];
{
let hello = card.borrow().items[0].clone(); // 1
card.borrow_mut().remove_item(&hello); // 2
}
let connector = &card.borrow().items[1]; // 3
assert!(matches!(
&connector.borrow().kind,
CardItemKind::Connector { from, .. } if from.upgrade().is_none()
));
}
При удалении объекта перекрестный ссылки на него удаляются автоматически. Но есть несколько нюансов.
Обратите внимание, что удаление элемента из карточки делается в два приема (1) (2). Этому есть причина — borrow checker.
В строке (1)
card.borrow()
создаётRef
, живущий до конца выражения.В строке (2) вызывается
card.borrow_mut()
, которому нужен изменяемый заём, но прошлыйRef
еще не отпущен.
Rust не допускает пересечения таких времен жизни, поэтому нужно сначала завершить неизменяемый заём — то есть разделить код на два выражения. И так повсюду. Вместо прямолинейного решения задачи программистам приходится искать обходные пути или как минимум писать, читать и сопровождать вдвое больше кода.
Кстати, весь код от строки (3) до конца делает (в псевдокоде):assert((card[1] as Connector).from.isEmpty)
Глубокое копирование с сохранением топологии
let new_doc = copy(&doc);
{ // Verify topological correctness
let new_card = &new_doc.borrow().cards[0];
let new_conn = &new_card.borrow().items[1];
if let CardItemKind::Connector { to, .. } = &new_conn.borrow().kind {
assert!(ptr::eq(
Rc::as_ptr(&new_card.borrow().items[0]),
to.upgrade().map(|rc| Rc::as_ptr(&rc)).unwrap_or(ptr::null())
));
}
if let CardItemKind::Button { target_card, .. } = &new_card.borrow().items[0].borrow().kind {
assert!(ptr::eq(
Rc::as_ptr(&new_card),
target_card.upgrade().map(|rc| Rc::as_ptr(&rc)).unwrap_or(ptr::null())
));
}
}
Копирование не является встроенной операцией. Оно выполняется точно таким же способом, как в С++-версии.
Обратите внимание, что проверка корректности двух ссылок на С++ записывалась бы так:
assert(new_doc->cards[0]->items[0] ==
std::dynamic_pointer_cast(new_doc->cards[0]->items[1])->to.lock());
assert(new_doc->cards[0] ==
std::dynamic_pointer_cast(new_doc->cards[0]->items[0])->target.lock());
Тоже не очень компактно и удобно, но все же раза в несколько раз проще.
Предотвращение мульти-владения (Runtime)
let result = doc.borrow_mut().add_card(
new_doc.borrow().cards[0].clone());
assert!(matches!(result, Err(Error::MultiParenting)));
Работает, но все проверки делаются вручную и в рантайме.
Предотвращение циклов (Runtime)
let group = CardItem::new_group();
let subgroup = CardItem::new_group();
assert!(add_subitem(&group, subgroup.clone()).is_ok());
let result = add_subitem(&subgroup, group.clone());
assert!(matches!(result, Err(Error::Loop)));
Аналогично предыдущему, тоже работает, но тоже вручную и в рантайме.
Оценка Rust CardDOM
Критерий | Что хорошо | Что плохо |
Безопасность памяти | Исключает UB, гарантирует корректный доступ к памяти (только в рамках safe подмножества) |
|
Предотвращение утечек | - | Утечки памяти |
Ясность владения | Четко выражено через | Отсутствует встроенная гарантия уникальности владения для DOM сценариев, мульти-владение проверяется только в рантайме. |
Глубокое копирование | - | Реализуется вручную |
Слабые ссылки (Weak) | Автоматически обнуляются при удалении таргетов | Возможны утечки т.к |
Устойчивость в рантайме | В рамках Safe Rust нет UB при доступе к памяти. | Бусидо: самоубийство программы через панику при любой подозрительной ситуации |
Выразительность | - | Реализация Rust CardDOM занимает около 330 строк, что делает её самой многословной среди языков, превосходя даже JavaScript с его ручной имитацией Weak. |
Эргономика | - | Требуется высокая когнитивная нагрузка и внимательное управление borrow/clone/Weak |
Вывод
Модель владения в Rust (Rc
, Weak
, RefCell
) гарантирует безопасную работу с памятью, но ценой сложности, многословия и ручного контроля за временем жизни ссылок и структурной целостностью. Она устраняет неопределённое поведение C++, но вводит новые риски — паники при заимствовании и рост когнитивной нагрузки: порой ручное жонглирование бесконечными borrow
/drop
/upgrade
/clone
тратит больше времени и сил, чем собственно решение задачи. Не решается и проблема утечек памяти.
Из-за отсутствия классического наследования полиморфизм реализуется через enum
или trait
-диспетчеризацию, что дробит логику и повышает связность программы.
Кроме того, значительная часть продакшн-кода на Rust использует unsafe
— напрямую или через зависимости — чтобы обойти чрезмерные ограничения и вернуть производительность, гибкость или доступ к низкоуровневым функциям. Это показывает противоречие в философии языка:
безопасность без падений остается недостижимым идеалом,
в то время как суровая реальность - это паники и многократно переусложненный код.
Изо всех исследованных языков в задаче поддержки объектной модели документов Раст показал себя хуже всего.
Какой язык протестировать следующим? GoLang? Python? Или отказаться от своего принципа не рекламировать Аргентум, и показать этот пример на нем?