Это третья статья в серии про DOM-подобные модели данных в различных языках программирования.
В предыдущих частях:
Сегодня мы рассмотрим как с Card DOM справился С++.
Код
Код на С++ почти вдвое компактнее JS-версии (всего ~158 строк), но это все равно слишком много для включения в статью, поэтому он доступен по ссылке: https://www.mycompiler.io/view/G4ypqaNpDml.
Детали реализации
Мы используем
weak_ptrдля перекрестных ссылок.Мы можем полагаться на автоматическую очистку объектов и обрыв перекрестных ссылок с помощью деструкторов.
Хотя узлы нашего дерева имеют ровно одного владельца, мы не можем использовать
unique_ptrпоскольку на них могут ссылатьсяweak_ptr. поэтому все владеющие ссылки в нашем DOM будутshared_ptr.Мы не можем использовать конструкторы копий для создания топологически-корректных копии объектных иерархии, поскольку такое копирование - двухпроходный процесс, он требует формирования и использования
map(оригинал->копия). Поэтому мы реализуем глубокое копирование вручную.Поскольку мы используем
shared_ptrдля представления всех владеющих указателей нам придется вручную детектировать наличие циклов и множественных владельцев в графе владения.Мы можем использовать
shared_ptr<const T>для представления разделяемых ресурсов. Это не даст стопроцентной гарантии неизменяемости, но может помочь поймать некоторые ошибки на стадии компиляции.Когда мы удаляем объект из DOM, на него могут указывать raw-указатели из стека. Даже если мы передаем параметры в функции и храним локальные ссылки как
shared_ptr<T>, мы все равно рискуем, потому чтоthisвсе равно передается какT*. Требуется или предельная аккуратность при кодировании или следование правилу, когда всякий вызов метода производится по временно созданномуshared_ptr<T>, который будет предотвращать случайное удаление объекта.
Примеры использования
Создание документа
auto doc = make_shared<Document>();
{
auto style = make_shared<const Style>("Times", 16.5, 600);
auto card = make_shared<Card>();
auto hello = make_shared<TextItem>("Hello", style);
auto button = make_shared<ButtonItem>("Click me", card);
auto conn = make_shared<ConnectorItem>(hello, button);
card->add_item(move(hello));
card->add_item(move(button));
card->add_item(move(conn));
card->add_item(make_shared<GroupItem>());
doc->add_item(move(card));
}

Разделяемые ресурсы: clone-on-mutation
auto hello_text = dynamic_pointer_cast<TextItem>(doc->items[0]->items[0]);
// Попытка изменить замороженный стиль
hello_text->style->size++; // Ошибка компиляции
// Изменение через копию
auto new_style = hello_text->style->clone();
new_style->size++;
hello_text->style = new_style;
В этом коде, объект, который уже считается разделяемым, все еще доступен по не-константной ссылке new_style
Продление времени жизни с помощью стековой ссылки
{
auto hello_text = dynamic_pointer_cast<TextItem>(doc->items[0]->items[0]);
doc->items[0]->remove_item(hello_text);
// hello_text всё ещё жив и доступен по перекрестной ссылке from из коннектора
assert(!dynamic_pointer_cast<ConnectorItem>(
doc->items[0]->items[1])->from.expired());
} // Удаление происходит здесь
Глубокое копирование с сохранением топологии
auto new_doc = deep_copy(doc);
// Убеждаемся, что в копии перекрестные ссылки показывают так же, как в оригинале
assert(new_doc->items[0]->items[0] ==
dynamic_pointer_cast<ConnectorItem>(
new_doc->items[0]->items[1])->to.lock());
assert(new_doc->items[0] ==
dynamic_pointer_cast<ButtonItem>(
new_doc->items[0]->items[0])->target_card.lock());
Защита от мульти-владения
try {
doc->add_item(new_doc->items[0]);
} catch (std::runtime_error&) {
std::cout << "multiparented!\n";
}
Предотвращение циклов
try {
auto group = make_shared<GroupItem>();
auto subgroup = make_shared<GroupItem>();
group->add_item(subgroup);
subgroup->add_item(group);
} catch (std::runtime_error&) {
std::cout << "loop\n";
}
Оценка: как C++ справился с DOM-подобными структурами
Критерий |
Да |
Но |
Безопасность памяти |
Умные указатели помогают. |
С++ - не дает гарантий безопасности. |
Предотвращение утечек |
Умные указатели и RAII помогают бороться с утечками. |
С++ не предотвращает утечки. |
Ясность владения |
Разделение |
За уникальностью владения приходится следить вручную. |
Копирование |
— |
Полностью ручная реализация. |
Слабые ссылки |
Обрываются автоматически, требуют проверки перед использованием. |
|
Устойчивость |
— |
С++ - небезопасный язык. При работе со структурами данных, даже с использованием умных указателей требуется дисциплина и аккуратность. |
Выразительность |
Код многословен: |
|
Момент обнаружения ошибок |
Проверки неизменяемости разделяемых ресурсов производятся при компиляции благодаря |
Проверки закольцовок и множественного владения - происходят только во время исполнения. Тесты обязательны. Много тестов. |
Итог: C++ дает контроль, но требует дисциплины, помогает но ничего не гарантирует
C++ предоставляет тонкий контроль над временем жизни и владением,
позволяя построить DOM-подобную структуру с относительно безопасными ссылками и минимальными утечками.
Однако отсутствие встроенной защиты памяти требует избегания сырых указателей и жесткой дисциплины при использовании умных указателей.
В приложении к DOM-подобным структурам язык когнитивно сложен и не безопасен.
Комментарии (33)

TimurZhoraev
18.10.2025 21:04Возникает несколько основополагающих вопросов:
1. Гарантия последовательности вызовов конструкторов и аллокаторов с аргументами
2. Наличие скрытых флагов, определяемых компиляторов, помимо полей классов и таблиц виртуальных функций ВФ
3. Гарантия одновременной аллокации таблиц ВФ, флагов вместе с объектом/иерархией
4. Отсутствие проблемы чтение-модификация-запись для флагов объекта через асинхронные процедуры (указатель снабжается мьютексом)
5. Управление через индексацию [i] или список prev-next для неявных механизмов
6. Управление размерностью счётчиков ссылок и другими атрибутами встраиваемыми в класс
7. Выравнивание объектов для возможной реализации в виде кастомного аллокатора в пуле объектов или использование готовых специализированных пулов
8. Управление графом объектов с множественными циклическими ссылками, при этом, существуют shared-объекты со стековым удалением (например, помечаемые флагом), в этом случае один объект-атрибут в байт может отложить деаллокацию гигабайта временных копий.
kotan-11 Автор
18.10.2025 21:04Слишком тезисно и слишком оторвано от темы DOM-подобных структур данных в C++. Позвольте процитировать классика: "Какой заяц? Какой орёл?"

TimurZhoraev
18.10.2025 21:04Как раз указаны основные проблемы характерные (не только для С++) для синтаксических деревьев, получаемых после парсинга HTML/CSS и других языков разметки-структурирования (включая XML/JSON). Всё колдовство вокруг итераторов - это сэкономить несколько строчек и выразить лаконичный обход по дереву, особенно что касается prev[N]-next[M] (множественные ссылки и кольцевые связи) и одновременного наличия атрибутов [0,1,2,...]->... Этот микс никогда не был эффективным за исключением хеш-таблиц. Копирование объектов и ссылок для определённого вида эффективнее (но хуже по объёму) в виде линейных массивов с секторами объектов, идентификаторами и смещением, включая матрицы инцидентности, но это уже изначально архитектурные изыскания с точки зрения оптимизации и написания в объектах своих микро-new() и delete(), которые не используют системные аллокаторы, включая наличие скрытых флажков в классе (мьютексы и пометка на удаление) и счётчиков ссылок. Smart pointer - это попытка универсальными (по большей мере не языковыми) методами закрыть сугубо специализированную проблему с не оптимальной (и не безопасной) привязкой к системным аллокаторам, вызывающих утечку вне сегмента, в простейших случаях это обладает эффективностью во всех остальных - необходимо уже учитывать то что написано в пунктах, но это уже не Smart Pointer в том виде в котором его предлагает Stdlib а, грубо говоря, полноценный Asyncio между объектами с очередями/семафорами, стеком удаления, флагами управления состоянием объекта (команды сборщику мусора) включая кучу итд.

kotan-11 Автор
18.10.2025 21:04У вас есть на примете язык/фреймворк который может эффективно реализовать задачу Card DOM (и вообще хэндлинг DOM-стурктур)?
Вы можете на нем реализовать эту задачу, чтобы показать этот язык/технологию с лучшей стороны?
Так же было бы неплохо провести gap-analysis, показав в чем преимущества и чем приходится ради этого пожертвовать.

rsashka
18.10.2025 21:04Ваша серия статей меня заинтересовала и я сейчас делаю реализацию DOM структуры на базе одной библиотеки для безопасной работы с памятью. Но у меня есть несколько вопросов по требованиям (ограничениям) для DOM объекта.
Контроль единственности владельца. Карточка не может быть вставлена в несколько документов одновременно и не может быть вставлена в один и тот же документ несколько раз. Аналогично с элементами карточек — они должны иметь строго одного владельца — или карточку или группу.
Почему накладывается такое ограничение? Почему не может быть двух владельцев у одного объекта?
Стили и битмапы — неизменяемые ресурсы, разделяемые между текстовыми блоками и картинками. Любое их изменение должно выполняться через copy-on-write.
Так не изменяемые или изменяемые через copy-on-write? Это принципиально различные требования, которые требуют совершенно разной реализации в коде (особенно при работе в нескольких потоках).

TimurZhoraev
18.10.2025 21:04Очень упрощённо, это реализуется на простом С как:
- битовое поле с прагмами aligned/pack с атрибутом volatile содержащее флаг пометки объекта на удаление и/или в процессе удаления (чтобы избежать цикла) в двусвязном списке, там же счётчик ссылок на объект
- копируемый массив ссылок вместо самих объектов. Как то рисовал это в аски-графике. Идея в том чтобы при удалении переносить из одного объекта в другой массив указателей до опустошения
- чтобы избежать проблему гонки данных и чтение-модификация-запись использовать протокол обмена данными между объектами на основе запрос-обработка-ответ в различных переменных (с гарантией отсутствия одновременного доступа, volatile 32/64 бита) на основе флажков
-имеется вектор указателей на большие объекты, а сами они расположены в линейной памяти по порядку. Размер объектов 4/8/16/32... слов. Строки и текстовые литералы - в отдельном массиве с аллокацией системой.
- системные malloc/free (new-delete) только для указанных выше кластеров объектов. Внутри объекта указатель заменяется на ID, равный смещению в массиве. В случае потери ссылки или битом объекте - высвобождается кластер, исключение возникает если ID за пределами или внутри удалённого кластера.-искусственная таблица виртуальных функций с заданным размещением - вызов обработчиков по указателю, в этом случае напрямую проверяется ClassID и управление становится более прозрачным, так как идентификатор класса есть уже протокол. Наподобие тому как раскидывать данные по объектам в иерархии прилетающие по RS-485.
DOM - это уже не просто ООП/итераторы а протокол согласования и обмена данными между объектами, очень грубо это TCP/IP между элементами, включая даже таймауты, когда ждём событие от кнопки, тормозящее всё остальное. Или деструктор, зависший на ожидании исключения при внезапно отпавшей сети.
Иными словами, для решения этой проблемы должен быть некий Python/Perl генератор, который формирует код хоть на ассемблере, но без костылей и с прозрачными механизмами, на входе - JSON с описанием того что должно быть в этой модели. Классическими языками это не победить в должной мере (быстро, компактно, не дорого, нужное подчеркнуть)
Во всех остальных случаях - это бодание с системными функциями и механизмами, реализующими SmartPointer, в итоге удобство превращается в довольно обвесистый код, содержащий борьбу с абстракциями вместо прямого решения проблемы. Как только видится dynamic/static/realloc и прочий cast, включая typeid и прочие внутренности класса, рушится сама концепция инкапсуляции и целостности объектов, этот трюк только для интерпретируемых языков.
YogMuskrat
Разве не получится избежать этого через
std::enable_shared_from_this?kotan-11 Автор
enable_shared_from_thisпозволяет конструироватьshared_ptrиз raw-поинтера. Но все равно при вызове любого метода нулевым невидимым параметром будет переданthisв виде сырого указателя. Пример:результат будет Card destroyed... Card accessed
Sazonov
То что this неявно передаётся - это одно. Тут больше речь про время жизни и простоту кода. enable_shared_from_this как раз таки решает этот вопрос.
kotan-11 Автор
Раскомментируй строчку в моем примере
: enable_shared_from_this<Card>- ничего не изменится,enable_shared_from_thisне решает проблему.Sazonov
В вашем коде других проблем хватает. Пока это выглядит как первая лабораторная работа по наследованию в ООП (ещё даже до инкапсуляции) и как желание поиграться с конструкциями языка си++. Детальный код ревью делать смысла нет, потому что архитектурно выбраны вообще не те инструменты. И в целом всё в кучу намешано. Небезопасный код, странные касты указателей, динамик касты и тп.
kotan-11 Автор
Если у вас есть идеи, как на С++ написать Card DOM свободный от утечек памяти, с проверкой циклов и мульти-парентинга во время компиляции, с автоматическим топологически-корректным копированием (я перечисляю основные проблемы из колонки "но"), приведите ваше решение в виде работающей реализации Card DOM на C++, и мы обсудим ваши идеи. Обещаю не придираться к качеству вашего экспериментально-демонстрационного кода
Sazonov
У меня не стоит задачи мериться написанием кода. Я лишь указал на серьезные слабости вашей статьи. Ниже привели более интересный ответ.
Если вам качество вашей статьи безразлично, то можете просто пропустить мой комментарий :)
kotan-11 Автор
Я описал набор критериев, по которым будет оцениваться способность языков управляться с DOM-образными структурами данных. Среди этих критериев нет требования инкапсуляции, требования понравиться Сазонову и еще каких-то других не относяхищся к делу требований. Там есть требование не течь, не падать, быть простым и понятным. Я дал (и по-прежнему даю) всем желающим возможность написать этот тест-бенчмарк на любых языках, я подождал неделю, и не получил никакого кода, ни на одном языке, но получил тонну обвинений в том, что я хочу украсть чужой труд, что моя предлагаемая структура данных - никому не нужна, и что ее нельзя реализовать на Расте, потому что он - системный, а структура 1С-ная, что требования не падать означают, что пользоваться эти апи будут дебилы, а требование не течь невыполнимо. Я взял и за несколько часов реализовал и проанализировал этот пример на JS и C++. Что я получаю в виде фидбека? Тонну оскорблений и никакой конкретики.
У моей статьи есть цель - показать сильные и слабые стороны С++ в приложении к DOM-структурам. Мой код это показал. Если вы считаете, что что-то из отмеченных выше слабостей языка можно исправить, заменив struct->class и насовав в код public/protected - флаг в руки, и вперед, доказывать. Если нет давайте пропустим ваш комментарий.
Sazonov
У вас не получилось показать сильные стороны си++
kotan-11 Автор
Вся колонка "Да" в таблице - это сильные стороны С++ в приложении к DOM-структурам. Да, их немного. Похоже С++ плохо справляется с такими задачами. Если вам кажется, что какие-то сильные стороны упущены - предложите - добавлю. Если вам кажется, что можно сделать другую реализацию задачи Card DOM на С++, которая покажет какие-то сильные стороны, упущенные моим решением - сделайте и опубликуйте отдельной статьей. Обсудим. Давайте переходить к конструктивному разговору.
Sazonov
Я про ваш код, а не про текст статьи. Что именно не так - уже написано в предыдущих комментариях.
Cfyz
А почему в данном примере Card::myMethod() удаляет объект, которым он не владеет?
Это фундаментальная проблема, которая лишь косвенно относится к this. Таким образом (модифицируя данные поперек владельца) можно все сломать и без this, и без указателей вообще.
kotan-11 Автор
Это минимальный пример, воспроизводящий проблему. В реальном примере в сфокуссированный элемент карточки пришло событие нажатой кнопки. Обработчик вызвал библиотченый метод, который вызвал обработчик события из другой легаси библиотеки, который активизировал стейт-менеджер написанный три года назад командой из Еревана, который удалил форму по идентификатору считанному из файла конфигурации. Да упало это на миллионе абонентских устройств, и баг нашелся по логам.
Cfyz
Вполне допускаю, что такой случай мог произойти, при наслоении разных библиотек и легаси в C/C++ что только не может произойти.
Но в статье и текущей ветке идет речь о разработке представления DOM-подобной структуры данных на C++ и поэтому возникает резонный вопрос что это за интерфейс такой у разрабатываемой структуры данных, который так легко позволяет выполнить delete this и прочие непотребства.
Вы справедливо замечаете, что надо следовать правилу вызова методов на отдельных объектах shared_ptr, только вот формулируете это как будто это такая особенность shared_ptr и с ним надо быть всегда предельно осторожным, чуть что и core dumped.
Хотя на самом деле просто любая сущность должна модифицировать только те данные, которыми она владеет. Нельзя удалять объект, если ты им не владеешь и не отвечаешь за его время жизни.
В случае shared_ptr это само собой подразумевает владение указателем. Это не выбор или быть предельно осторожным, или создавать временные shared_ptr, или что-то еще. Если код что-то делает с экземпляром shared_ptr, которым он не владеет, то нет никакого shared_ptr, вы просто работаете с самым обычным голым указателем черти на что. Конечно так дела не делаются.
В упрощенном примере основной баг в том, что main() объектом не владеет. Поэтому любая модификация глобального состояния сразу же все ломает.
И то, что this передается как T* никак к shared_ptr не относится. Указатель на this не владеет временем жизни объекта, его нельзя передавать куда-то, где указатель может быть сохранен или тем более использован для удаления. Если надо куда-то передать для управления объектом ссылку на объект изнутри метода, то это или shared_from_this(), или никак.
kotan-11 Автор
Вы когда-нибудь создавали продукт силами четырёх команд, разбросанных по трем континентам - с историей в десятилетия, десятками мажорных версий, пять из которых одновременно в продакшене у разных клиентов? Если да, то вы понимаете, насколько важно, чтобы структуры данных не рушились от любого чихa. Поэтому в исходной статье появилось требование "Устойчивость", а в сценариях - "Удаление карточки из метода элемента этой карточки". Если не поддержать это требование на фундаментальном уровне, компания потеряет миллионы и репутацию - и не спасут ни дисциплина владения, ни сферические архитектурные принципы в вакууме.
YogMuskrat
Вероятно, я неправильно понял тот ваш пассаж, который цитировал. Думал, что речь про передачу указателя на себя во внешние функции, а не про неявный первый параметр.
В любом случае, я не понимаю, как связаны умные указатели и ваш пример. В нем как-раз нарушается правило единичного владения, про которое вы пишите в статье. Если узлы имеют ровно одного владельца, то почему владеющий указатель не заинкапсулирован внутри этого владельца, а лежит в глобальной переменной?
Согласен с соседними комментариями, что это очень неудачный пример кода.
kotan-11 Автор
Этот пример кода имеет единственной своей целью продемонстрировать, что
enable_shared_from_thisникак не защищает объект от преждевременного удаления. И он это демонстрирует. У вас есть пример кода, который демонстрирует обратное?YogMuskrat
Он и не должен от него защищать.
Я же написал, что упомянул
shared_from_thisв контексте передачиthisв параметры функции. В вашем примере такой передачи нет.rsashka
Класс с родителем
enable_shared_from_this()имеет управляющую структуру дляshared_ptrв самом объекте класса (что и позволяет ему обращаться к ней изнутри объект этого самого класса), тогда как конструктор из raw указателя выгляди какshared_ptr( raw_ptr ).В строчке кода
c = nullptr;вы записываете в переменную с указатель на новый объект, и тут как раз и вызывается конструкторshared_ptr( raw_ptr ), а перед этим, естественно, вызывается деструктор у предыдущего объектаДа, в общем случае все так. Но это никак не связано с
enable_shared_from_this(). Просто у вас ошибка в коде. В методеmyMethod()вы удаляете глобальный объект, к которому потом пытаетесь обратиться в функцииmain()kotan-11 Автор
В моей реализации Card DOM в этой статье все узлы DOM-а наследуют у
enable_shared_from_thisи используют этот факт, чтобы создавать weak_ptr и shred_ptr внутри своих методов. То есть я использую этот базовый класс именно так как у вас описано.Наше разногласие в другом в другом:
Вы согласны, что enable_shared_from_this не решает проблему передачи this как T*?
Если нет, приведите код.
rsashka
enable_shared_from_thisрешает только одну задачу, получение доступа к управляющей структуреshred_ptrизнутри объекта класса. Больше ни для чего другого этот шаблоне не предназначен и не передает ничего и никуда.shred_ptrи this - это совершенно разные сущности не связанные между собой (точнееshred_ptrхранит указатель, но это может быть любой указатель, не обязательно на объект).kotan-11 Автор
Вы согласились со мной, что enable_shared_from_this не решает проблему передачи this как T* и тут же поставили мне минус. Наверное нам стоит прекратить общение.
rsashka
Вы читаете что вам пишут?
enable_shared_from_thisне решает проблему передачи this как T*, так как он не предназначен для этого.Минус я вам не ставил, а наоборот, давно плюсанул в карму (хотя с таким подходом к общению наверно скоро действительно дойдет и до минуса).
kotan-11 Автор
Я не записываю в этой строчке в переменную
суказатель на новый объект.здесь не вызывается
shared_ptr( raw_ptr )здесь не вызывается этот конструктор, и даже если бы тут присваивался неnullptrтут бы все равно вызывался operator=, а не конструктор.деструктор вызывается как часть оператора= после конструирования нового объекта, т.к. сконструирвоанный объект - параметр функции operator=, а по стандарту вычисление параметров выполняется до выполнения функции, даже при инлайне.
Покажите строчку в функции
mainв которой происходит обращение к удаленному объекту.rsashka
Сперва вызывается конструктор нового объекта, потом оператор присвоения, потом деструктор для старого объекта.
c->myMethod();kotan-11 Автор
Видите, здесь вызывается shared_ptr(nullptr_t) а не shared_ptr( raw_ptr ).
Далее вызывается деструктор не старого объекта а временного null-shared-ptr-а
В этой строчке
c->myMethod();объект еще не удален.Не используйте вывод конкретного компилятора для конкретной платформы с конкретными ключами компиляции. Стандарт разрешает не создавать здесь временных объектов, и в не-дебаг режиме они с огромной вероятностью не будут создаваться.
rsashka
Потому что nullptr_t в С++ отдельный тип и вызывается именно этот конструктор, а не shared_ptr( 0 ).
Согласен, при вызове метода, объект еще существует. Он удаляется во время вызова.
Как еще можно продемонстрировать создаваемый код, если не с помощью вывода компилятора?