Это вторая статья в серии, посвященной сравнению языков программирования по необычному критерию — как они справляются с организацией DOM-подобных структур данных.
Вводная часть тут: Настоящий тест для языков программирования. В ней рассказывается:
что такое DOM-подобные структуры данных,
почему они присутствуют буквально в каждом приложении,
определяется базовый бенчмарк и критерии оценки, по которым мы будем сравнивать поддержку этих структур во всех языках программирования.
Сегодня мы рассмотрим как JavaScript справляется с задачей Card DOM.
Код
Полный пример кода слишком большой (~330 строк), чтобы опубликовать его тут.
Он доступен на JSFiddle:https://jsfiddle.net/bq5gah7r/1/.
Реализация Card DOM показала ряд сильных и слабых сторон JavaScript применительно к DOM-подобным структурам данных.
Где JS хорош из коробки
JavaScript обеспечивает безопасную работу с памятью — без use-after-free и висячих указателей.
Сборщик мусора предотвращает утечки, пусть и с отложенным освобождением памяти.
Полиморфизм позволяeт естественно моделировать предметную область сохраняя модульную структуру программы.
Неизменяемость через заморозку: Общие ресурсы, такие как Style и Bitmap, фиксируются с помощью Object.freeze() для предотвращения случайных изменений.
Где JS не справляется
Браузер предоставляет встроенные механизмы для строгого контроля уникальности владения и для предотвращения циклических ссылок в дереве HTML DOM. Но JavaScript не предоставляет такие же механизмы для JS-объектов, поэтому разработчикам приходится реализовывать их самостоятельно.
JavaScript имеет слабые указатели - WeakRef, но они непригодны для DOM-сценариев, так как они продолжают указывать на объект, пока он не будет удален сборщиком мусора, тогда как нам требуется немедленное удаление ссылок на объекты при их удалении из дерева.
Встроенная операция копирования с учетом топологии (structuredClone) не поддерживает пользовательские объекты и не различает композитные, агрегатные и не владеющие ссылки, что делает ее непригодной для использования в нашем Card DOM.
Особенности реализации CardDOM с учетом ограничений
Контроль циклов и множественного владения: Каждый узел DOM получает поле parent для обеспечения проверки уникальности владения и отсутствия закольцовок с выбросом исключений при нарушениях. Эта проверка будет вызываться при добавлении элемента в дерево.
Не владеющие перекрестные ссылки: Узлы, на которые можно ссылаться, хранят коллекции входящих ссылок (inboundButtons, inboundConnectors). При удалении им приходит detach().
Каскадное удаление: Удаление узла рекурсивно очищает всё его поддерево.
-
Двухфазное глубокое копирование:
Первая фаза - создается копия дерева и map объектов(оригинал -> копия).
Вторая фаза - восстановление топологии перекрестных ссылок в копии. Это требует значительного объема рукописного кода.
Что не получилось
Нельзя декларативно указать, что в Text-style и Image-bitmap можно хранить только frozen-объекты.
Когнитивная нагрузка: Код включает много ручной логики для методов deepCopy(), detach(), resolve() и т. д. Разработчики должны тщательно отслеживать жизненный цикл объектов и корректно вызывать detach*() во избежание кросс-ссылок на убитые объекты.
Ссылки из стека могут указывать на объекты в полу-убитом состоянии (с уже оторванными перекрестными ссылками).
Примеры использования
Создание документа
const doc = new Document();
const normal = makeSharedResource(new Style("Times", 16.5, 600));
const card = new Card();
doc.addCard(card);
const helloText = new TextItem("Hello", normal);
card.addItem(helloText);
const buttonOk = new ButtonItem("Click me", card);
card.addItem(buttonOk);
card.addItem(new ConnectorItem(helloText, buttonOk));

Копирование
const newDoc = deepCopy(doc);
// Неизменяемые ресурсы - шарятся,
// Перекрестные ссылки - сохраняют топологию
// Владеющие - определяют дерево и задают область копирования.

Стили: копирование при изменениях
// Стиль нельзя изменять - он замороженный
// doc.cards[0].items[0].style.font = "Helvetica"; // Throws: Immutable!
// Но можно изменять его копию.
doc.cards[0].items[0].mutateStyle(style => {
style.font = "Helvetica";
});
Вставка с проверками на циклы и multiparenting
// Детектирование циклов
const group = new GroupItem();
const subgroup = new GroupItem();
group.addItem(subgroup);
subgroup.addItem(group); // Throws: Loop detected!
// Нельзя вставлять один элемент в два места дерева
// doc.addCard(newDoc.cards[0]); // Throws: Multiparenting attempt!
// Но копию вставлять можно:
doc.addCard(deepCopy(newDoc.cards[0]));
Удаление элемента с обрывом перекрестных связей
newDoc.cards[0].removeItem(newDoc.cards[0].items[0]);
// "Hello" удалился из карточки, и на него больше не ссылается коннектор.

Оценка: как JavaScript справился с DOM-подобными структурами
Критерий |
Да |
Но |
Безопасность памяти |
Безопасность памяти гарантирована: нет указателей на мусор, двойных освобождений и падений. |
Однако после отсоединения узлов от DOM и выполнения detach() эти логически «уничтоженные» объекты могут оставаться достижимыми из стека, частично разрушенные с удаленными перекрестными ссылками. |
Предотвращение утечек |
Освобождением занимается GC. |
GC = непредсказуемость моментов освобождения, отсутствие гарантии что любой конкретный объект будет удален раньше чем завершится программа, невозможность привязывать внешние ресурсы к времени жизни объектов (отсюда требование ручной очистки). Освобождение объектов привязано к достижимости по графу ссылок, что требует аккуратно следить, чтобы логически не-владеющие ссылки или аккуратно разрывались вручную или реализовывалось через WeakRef иначе будет утечка. |
Ясность владения |
— |
Нет различий между агрегатами, композитами и не владеющими ссылками. Всё владение реализуется вручную — через подписки и очистку, проверки при вставке и ручное копирование. Частичные обходные решения есть (например, structuredClone с transfer), но они фрагментарны. |
Копирование |
— |
Пишется вручную. |
Слабые ссылки |
Есть WeakRef. |
WeakRef разрывается только при отложенном удалении объекта, когда/если до него доберется GC. А если нужно отписываться при логическом удалении объекта (а это почти всегда так) WeakRef бесполезен. |
Устойчивость: Переживает ли система произвольные изменения? |
Не падает. |
Логическую целостность данных приходится обеспечивать вручную. |
Выразительность: Можно ли это описать без боли и избыточности? |
Использование Card DOM относительно просто, если вручную следить за вставкой, удалением и присваиванием перекрестных ссылок. |
Реализация не декларативна, сложна и требует обширных тестов. Любое изменение структуры классов влечет переработку кода. |
Момент обнаружения ошибок |
— |
Все ошибки целостности структур данных выявляются только во время исполнения, даже при использовании TypeScript. Тесты обязательны и обширны. |
Итог: JavaScript справляется, но требует немалых усилий
JavaScript позволяет создать DOM-подобный граф, если:
вручную контролировать владение,
явно отслеживать слабые ссылки,
реализовать вручную логику глубокого копирования,
полагаться на реализованные вручную проверки, происходящие в рантайме.
Это решение лучше ручного управления памятью, но далеко от идеала.
Парадоксально, что сборщик мусора, несмотря на свою цену —
по памяти,
производительности,
и при своих непредсказуемых паузах, —
дает взамен сравнительно немного:
минимум эргономики,
низкую защиту целостности структур,
и отсутствие автоматизации при работе с графами объектов.
Эти выводы справедливы и для других языков со сборкой мусора: Java, Kotlin, C#, Python, Dart, Go, TypeScript. Все они сталкиваются с теми же ограничениями при работе со сложными изменяемыми графами объектов.
Следующий — C++.
vita55555
Приветствую,
ссылка на вводную статью правильная ?
https://jsfiddle.net/bq5gah7r/1/
kotan-11 Автор
Огромное спасибо, исправил.