Это четвертая статья в серии про DOM-подобные модели данных в различных языках программирования.

В прошлых сериях:

Сегодня мы рассмотрим реализацию Card DOM задачи на языке Rust.

Исходная задача

Краткий повтор, чтобы не ходить по ссылкам:

  • Для сравнения языков программирования в задачах на обработку иерархий объектов предлагается тестовое задание — упрощенный "редактор карточек".

  • Документ редактора содержит несколько карточек, каждая из которых хранит элементы: текстовые блоки со стилями, изображения, кнопки, коннекторы и группы (вложенные контейнеры).

  • Редактору требуется: копирование, удаление, редактирование документов, карточек и их элементов; изменение стилей и работа с перекрестными ссылками (напирмер, кнопки ссылаются на карточки, а коннекторы — на элементы карточек). Все операции обязаны выполняться корректно и безопасно, без сбоев и падений при удалении объектов.

  • Документы и элементы изменяемы, тогда как стили и битмапы — неизменяемые и общие для разных элементов и модифицируются по принципу copy-on-write.

  • Удаление любого объекта должно автоматически обрывать связанные ссылки, а попытки обращения к ним — контролироваться без крашей.

  • При копировании карточек и элементов необходимо сохранять структуру связей, чтобы копии ссылались на копии, а не на оригиналы.

  • В иерархии не должно быть закольцовок. Группы не могут содержать сами себя или свои подгруппы, а каждый элемент карточки должен иметь одного владельца — карточку или группу.

Все остальные подробности — в оригинальной статье.


Реализация на Rust

Полный исходный код (330 строк) доступен в Rust Playground.

Детали реализации:

  • Rc<RefCell> для композиции. Как и в случае с unique_ptr в C++ мы не можем использовать Box<T> для хранения DomNode поскольку на них могут ссылаться перекрестные ссылки. Дерево изменяемых объектов будет определяться Rc<RefCell<T>>.

  • Weak<T> для перекрестных связей. Кнопки и коннекторы хранят Weak<RefCell<T>> на целевые элементы или карточки, предотвращая циклы.

  • Rc<T> для разделяемых ресурсов. Стили и битмапы шарятся между элементами карточек через Rc<T>, обеспечивая общее владение и неизменяемость данных.

  • Глубокое копирование. Выполняется вручную в два прохода через 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<ConnectorItem>(new_doc->cards[0]->items[1])->to.lock());

assert(new_doc->cards[0] ==
   std::dynamic_pointer_cast<ButtonItem>(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 подмножества)

RefCell может вызвать панику при нарушении правил заимствования.

Предотвращение утечек

-

Утечки памяти шерифа не волнуют считаются безопасными в Расте (цитата из документации)

Ясность владения

Четко выражено через Rc<RefCell<T> Rc<T> и Weak<T>

Отсутствует встроенная гарантия уникальности владения для DOM сценариев, мульти-владение проверяется только в рантайме.

Глубокое копирование

-

Реализуется вручную

Слабые ссылки (Weak)

Автоматически обнуляются при удалении таргетов

Возможны утечки т.к upgrade создает Rc с множественным владением. Возможны падения, т.к. нет гарантии что завернутый RefCell не позаимствован

Устойчивость в рантайме

В рамках Safe Rust нет UB при доступе к памяти.

Бусидо: самоубийство программы через панику при любой подозрительной ситуации

Выразительность

-

Реализация Rust CardDOM занимает около 330 строк, что делает её самой многословной среди языков, превосходя даже JavaScript с его ручной имитацией Weak.

Эргономика

-

Создает высокую когнитивную нагрузку, требует внимательного ручного управления borrow/clone/drop


Вывод

Модель владения в Rust (Rc, Weak, RefCell) гарантирует безопасную работу с памятью, но ценой сложности, многословия и ручного контроля за временем жизни ссылок и структурной целостностью. Она устраняет неопределённое поведение C++, но вводит новые риски — паники при заимствовании и рост когнитивной нагрузки: порой ручное жонглирование бесконечными borrow/drop/upgrade/clone тратит больше времени и сил, чем собственно решение задачи. Не решается и проблема утечек памяти.

Из-за отсутствия классического наследования полиморфизм реализуется через enum или trait-диспетчеризацию, что дробит логику и повышает связность программы.

Кроме того, значительная часть продакшн-кода на Rust использует unsafe — напрямую или через зависимости — чтобы обойти чрезмерные ограничения и вернуть производительность, гибкость или доступ к низкоуровневым функциям. Это показывает противоречие в философии языка:

  • безопасность без падений остается недостижимым идеалом,

  • в то время как суровая реальность — это паники и многократно переусложненный код.

Изо всех исследованных языков в задаче поддержки объектной модели документов Раст показал себя хуже всего.

Какой язык протестировать следующим? GoLang? Python? Или отказаться от своего принципа не рекламировать Аргентум, и показать этот пример на нем?

ЗЫ. По просьбам читателей сравнительная таблица для других экспериментальных и не очень языков

Критерий

Zig, Odin, Jai

GoLang, Python, The V

Управление памятью

Ручное

GC

Безопасность памяти

нет

есть, (в V безопасность отключаемая)

Предотвращение утечек

нет

eventual, освобождение конкретной аллокации не гарантировано

Ясность владения

нет, все указатели одинаковые

нет, все указатели одинаковые

Глубокое копирование

вручную

вручную

Слабые ссылки (Weak)

нет

нет или GC-bound, то есть отложенные

Устойчивость в рантайме

нет

из-за доступа к памяти не упадет

Выразительность

Для операций с DOM аналогично Си

Для операций с DOM аналогично JS

Обнаружение множественного владения, закольцовок в графе, мутаций неизменяемых объектов

Вручную в рантайме

Вручную в рантайме

Комментарии (7)


  1. Lainhard
    14.11.2025 05:17

    Какой язык протестировать следующим? GoLang? Python?

    brainfuck


  1. lkik
    14.11.2025 05:17

    Zig, The V


  1. domix32
    14.11.2025 05:17

    Требуется высокая когнитивная нагрузка и внимательное управление borrow/clone/Weak

    Мне интересно а что мешало скрыть все эти свистоплясания за методами? Rc::downgrade превратился бы в вызов .into(), а то и вовсе можно было бы обойтись сделав constraint а ля Rc<impl CardItem>. Да и builder pattern неплохо бы сократил необходимость в ассертах.


    1. kotan-11 Автор
      14.11.2025 05:17

      Вы всегда можете написать и показать свой более хороший вариант. Или еще проще - взять мой 330-строчный код по ссылке и отрефакторить его в соответствии с вашими предложениями. Это позволит одновременно показать более идеоматический подход и сравнить плюсы и минусы разных вариантов.


  1. domix32
    14.11.2025 05:17

    Какой язык протестировать следующим? GoLang? Python? Или отказаться от своего принципа не рекламировать Аргентум, и показать этот пример на нем?

    Предлагаю попробовать Odin и его модель композиции. Предложил бы попробовать Jai, но его компилятор сложно добыть. А вообще было бы неплохо иметь рефенсную имплементацию на Аргентуме, чтобы понимать к чему надо стремиться.

    Отдельно хотелось бы попросить сделать репозиторий с вашими имплементациями на разных языках.


    1. kotan-11 Автор
      14.11.2025 05:17

      Добавил табличку для сравнения Zig, Odin, Jai, GoLang, Python, The V.
      Все перечисленные языки попали в две категории - с ручным управлением и с GC.

      Все языки построенные на сборщике мусора будут иметь реализацию DOMа аналогичную моему предыдущему примеру на JS. А все языки с ручными управлением памятью зарание и гарантированно уступают всему ранее рассмотренному.

      При этом я не говорю, что эти языки плохие. Просто сфера их приложения находится не в области DOM-задач.


    1. kotan-11 Автор
      14.11.2025 05:17

      А вообще было бы неплохо иметь рефенсную имплементацию на Аргентуме, чтобы понимать к чему надо стремиться. Отдельно хотелось бы попросить сделать репозиторий с вашими имплементациями на разных языках.

      сделаю