Это вторая статья в серии, посвященной сравнению языков программирования по необычному критерию — как они справляются с организацией 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++.
