Как стать автором
Обновить

Создание объектов без конструктора по умолчанию в C++: искусство владения памятью

Уровень сложностиСредний
Время на прочтение6 мин
Количество просмотров4.5K
Всего голосов 7: ↑6 и ↓1+6
Комментарии18

Комментарии 18

Но если наш тип POD (Plain Old Data)

То зачем удалять его (по факту пустой) конструктор?

Иногда намеренно удаляют конструктор по умолчанию, даже если он тривиальный — например, у простых структур (POD), которые содержат только данные. Это делается не потому, что конструктор вреден, а чтобы запретить создавать объекты случайно или без явной инициализации .

Допустим есть структура Point { int x; int y; }. Можно создать её так: Point p;

Но тогда поля x и y будут иметь неопределённые значения. Это может привести к багам, если этот объект представляет с собой объект в пространстве для какой-то системы. Для это и удаляют конструктор по умолчанию

С таким вариантом согласен - но тогда получается, что сначала явно (и по делу) запрещаем, а потом героически преодолеваем это запрет. Какой смысл в такой архитектуре в целом? Или речь о том достаточно локализованном коде, который предоставляет интерфейс для создания таких объектов?

Цель статьи — не навязывать стиль программирования или подходы, а показать, как работают механизмы языка в нестандартных ситуациях. Это скорее технический эксперимент и разбор того, что возможно в C++, когда хочется сделать то, что "по документации нельзя". Ваш комментарий абсолютно верный — в массовом применении такие трюки могут быть избыточными или даже вредными. Но в узких, специфических задачах они могут дать полезное понимание устройства языка и памяти.

Согласен, если просто запрещать конструкторы, а потом их героически обходить — это похоже на игру в кошки-мышки.
Но цель статьи — показать не «как надо», а «что возможно». Это скорее исследование, чем рекомендация.

"Программирование — это не только про то, как решить задачу, но и про то, чтобы попробовать сделать то, что «нельзя». Порой самые интересные решения рождаются именно там, где стандартные инструменты заканчиваются.
И если нельзя — не значит невозможно. Просто нужно найти свой путь."

То есть, это не про лучшие практики для повседневного кода, а про то, как устроен язык изнутри, и какие возможности он даёт тем, кто хочет заглянуть за границы привычного.

std::launder(static_cast<T *>(std::memmove(ptr, ptr, sizeof(T))));

В 17 тоже самое через memcpy между массивом байтов/чаров. И без UB.

Буханка-троллейбус.жпг

Думается, у вас ошибка в тексте:

Важно: мы не создаём объект в строгом смысле. Это UB по стандарту, если объект не был создан явно через placement new или иной способом, который инициализирует объект. Но если наш тип POD (Plain Old Data), то такое поведение часто работает в реальных реализациях.

Вместо часто работает должно было бы быть пока еще работает

После добавления в язык std::start_lifetime_as у компиляторщиков развязаны руки для того, чтобы начать эксплуатировать UB, на котором построен ваш код.

Вы правы, этот код потенциально опирается на поведение, не гарантированное стандартом. Однако важно учитывать контекст: статья рассматривает практики, применимые в C++17 , где ещё не было std::start_lifetime_as , и где компиляторы действительно трактовали такие конструкции как рабочие.

В данной статье рассматриваются методы создания объектов без использования конструктора по умолчанию с использованием возможностей стандарта C++17 , который предоставляет гибкие инструменты управления памятью и типобезопасностью

Да, с точки зрения современных стандартов (например, C++20 и новее), многие практики из C++17 могут считаться потенциально некорректными. Но если речь о реальности C++17 — такие подходы остаются рабочими

Но если речь о реальности C++17 — такие подходы остаются рабочими

Формально они-то как раз и не рабочие. И никто не даст вам гарантии, что условный clang-25, который начнет эксплуатировать данный UB, оставит старое поведение для C++17. Т.е. если кому-то взбредет в голову использовать описанный вами подход в своем коде, то это будет похоже на закладку мины замедленного действия. Работает, работает, о том, как оно сделано, уже никто и не знает, т.к. автор ушел в закат пару лет назад. В один прекрасный момент случается обновление компилятора и разбирайся потом почему перестало работать то, что работало.

Так что не вводите в заблуждение читателей. То, что вы описываете -- это хак, который пока что работает, т.к. до C++17 не было std::launder и компиляторщики не позволяли себе эксплуатировать этот UB. После C++20 и C++23 руки у них развязаны. И хак этот не имеет отношения к " гибким инструментам управления памятью и типобезопасностью".

Вы правы: описанный подход формально является неопределённым поведением согласно стандарту C++. Мы не создаём объект в строгом смысле — мы просто интерпретируем сырую память как объект. Это UB, и это нельзя игнорировать.

Однако цель статьи — не дать рекомендации к использованию в production-коде, а показать, как устроена работа с памятью «под капотом» , и какие техники применяются в определённых нишевых задачах: сериализация, memory-mapped I/O, реализация собственных контейнеров, low-level оптимизации.

Программирование — это не только про то, как решить задачу, но и про то, чтобы попробовать сделать то, что «нельзя». Порой самые интересные решения рождаются именно там, где стандартные инструменты заканчиваются. И C++ — это язык, который позволяет заглянуть за эти границы.

Фраза про "работает в C++17" — это констатация факта, а не призыв использовать такой код везде. Да, современные компиляторы могут начать активно эксплуатировать такое UB, особенно с появлением std::start_lifetime_as. Это действительно может привести к внезапным багам после обновления компилятора — вы абсолютно правы.

Но если обновился компилятор, и код перестал работать — это проблема разработчика , а не языка. Если он выбрал путь работы с raw-памятью, значит, он должен понимать, что делает, и быть готов к последствиям. В C++ вам дают пистолет. А уже вам решать — стрелять ли из него, и куда. Компилятор же лишь периодически меняет направление ствола, пока вы не глядя жмёте на спусковой крючок.

Так что пусть статья будет не руководством к действию, а скорее картой минного поля — чтобы те, кто всё равно решится пройти этим путём, хотя бы знали, где они идут.

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

как устроена работа с памятью «под капотом»

Простите за прямоту, но этого в статье нет от слова совсем.

И не стоит меня обвинять за то, что кто-то применит этот материал не по назначению

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

Вы абсолютно правы — в статье действительно не хватает важных предупреждений о рисках использования описанного подхода, а также ссылок на современные и безопасные альтернативы. Это упущение с моей стороны.

Что до вопросов ответственности — в следующих материалах я обязательно это учту.

Что касается дальнейшей дискуссии — считаю, что мы сейчас выходим за рамки программирования и технической части статьи, поэтому давайте оставим разговор здесь. Ещё раз благодарю за замечание — оно поможет сделать материал лучше.

Если говорить о технической составляющей, то применение std::launder для заявленных вами целей выглядит избыточным и в C++17. std::launder предназначен для других целей, следовательно, не нужен. А если мы полагаемся на то, что пока имеющиеся компиляторы не эксплуатируют данный UB, то достаточно простого reinterpret_cast-а.

И если уж и писать статью, то про какой-то условный:

template<typename T>
[[nodiscard]]
T *
my_start_lifetime_as(void * raw_ptr) noexcept {
#if defined(__cpp_lib_start_lifetime_as)
  // При наличии языковых средств действуем по фен-шую.
  return std::start_lifetime_as<T>(raw_ptr);
#else
  // Скрещиваем пальцы и надеемся, что компиляторщики пока еще
  // в своем уме (но это не точно).
  return reinterpret_cast<T *>(raw_ptr);
#endif
}

Насколько понял, идеи, использованные в статье используются (и имеют смысл) только для pod (можно даже сузить до trivially_copyable) типов.

И тогда предлагаемое решение выглядит очень уж замороченным для "условной" десериализации данных.

По-моему, здесь напрашивается вариант с созданием экземпляра через один из штатных способов (конструктор с параметрами/фабрика и др.) и потом делаем копирование через memcpy или вычитывание из файла. И если тип is_trivially_copyable, то это штатно разрешено. В ином случае - вам это не нужно.

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

---

Также согласен с комментариями выше: закладывать мину замедленного действия, которая может позже сработать - это очень нехорошо. Не надо так делать!

У меня вопрос про конструкцию

alignas(A) char data[sizeof(A)];
A* a = std::launder(reinterpret_cast<A*>(data));

Если A не POD тип? Если он наследуется от какого-то Base который имеет свои поля, который наследуется от некоего IAbstract у которого есть виртуальные методы реализуемые в A. Какие могут быть проблемы в c++17?

VMT никто не инициализирует, оно тупо упадет на первом же виртуальном вызове

У вас контейнер не удовлетворяет требованиям AllocatorAwareContainer и непонятно зачем вы прикрутили аллокатор в качестве шаблонного аргумента. С другими аллокатором может не сработать, особенно со statefull аллокаторами ,полиморфными аллокаторами как их частным случаем и std::scoped_allocator_adaptor. Будут очень сложно обнаруживаемые ошибки в стандартных контейнерах , которые содержат ваш контейнер как тип связанные с пропогацией аллокаторов. И другие веселости связанные с пропагацией аллокаторов.Попробуйте прокинуть все через allocator traits. И переопределить assignment операторы и copy конструктор для соответствующих вариантов пропагации аллокаторов.

И еще лучше вставить явный static assert на тип шаблонного аргумента контейнера , что он является pod.

Моя цель — сохранить концепцию POD-массива: не вызывать конструкторы и деструкторы, хранить данные в нулевой памяти и использовать пользовательский аллокатор. При этом можно сделать класс более совместимым со стандартными механизмами. К примеру, для этого можно перейти с прямых вызовов allocate и deallocate на использование std::allocator_traits<Allocator>, чтобы обеспечить поддержку разных типов аллокаторов, включая stateful и полиморфные. Также можно добавить корректную обработку политик распространения аллокаторов, таких как propagate_on_container_copy_assignment, propagate_on_container_move_assignment и propagate_on_container_swap, чтобы правильно управлять тем, как аллокатор передаётся при копировании, перемещении и свопе. Кроме того, можно реализовать копирующий и перемещающий конструкторы, а также операторы присваивания так, чтобы они учитывали аллокатор и соблюдали ожидаемое поведение стандартных контейнеров. Наконец, можно обеспечить совместимость с механизмами std::uses_allocator и std::scoped_allocator_adaptor, чтобы контейнер можно было безопасно использовать внутри других стандартных абстракций. Это позволит сохранить изначальную идею POD-хранилища.

Если вам действительно необходим этот функционал, вы можете сделать форк проекта и реализовать поддержку политик распространения аллокаторов, корректную работу с std::allocator_traits, а также добавить совместимость с std::uses_allocator и std::scoped_allocator_adaptor

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации