В C++ мы привыкли к тому, что создание объекта неизбежно связано с вызовом конструктора. Однако иногда возникают ситуации, когда конструктор по умолчанию отсутствует или удален (= delete
), а нам всё равно нужно получить корректный объект. Это может быть особенно актуально при работе с системным программированием, сериализацией/десериализацией, оптимизациями под производительность или даже при реализации своих собственных контейнеров.
В данной статье рассматриваются методы создания объектов без использования конструктора по умолчанию с использованием возможностей стандарта C++17 , который предоставляет гибкие инструменты управления памятью и типобезопасностью. Мы рассмотрим техники, которые позволяют работать с такими объектами напрямую, сохраняя контроль над процессом инициализации и временем жизни объектов.
Но как же создать объект, если его конструктор нельзя вызвать? Ответ лежит в понимании того, как устроена память в C++, и в правильном использовании возможностей языка.
Проблема: удалённый конструктор по умолчанию
Рассмотрим простую структуру:
struct A {
A() = delete;
int x;
};
Попробуем создать объект:
int main() {
A a; // Ошибка: использование удалённого конструктора
}
Компилятор блокирует создание объекта, потому что конструктор был явно удалён. Однако на самом деле память под A
всё ещё можно выделить — просто компилятор не даст нам вызвать конструктор. А что если мы хотим создать объект без вызова конструктора?
Погружение: когда конструктор мешает
Иногда наличие обязательного конструктора становится препятствием, особенно в следующих задачах:
Сериализация/десериализация (например, восстановление состояния из бинарного файла).
Работа с отображённой в память областью (memory-mapped I/O).
Реализация собственных контейнеров или аллокаторов.
Оптимизация производительности (отложенная инициализация).
Решение 1: Ручное управление памятью через placement new
C++ предоставляет механизм placement new
, который позволяет размещать объекты в уже выделенной памяти без дополнительного выделения:
alignas(A) char buffer[sizeof(A)];
A* a = new(buffer) A; // placement new
Однако это тоже вызывает конструктор. Если он удален, то этот код не скомпилируется.
Решение 2: Обход конструктора через union
Можно воспользоваться union’ами, где один из членов используется только для заполнения памяти:
union storage_t {
A a;
char buffer[sizeof(A)];
};
storage_t s;
new(&s.a) A(); // снова placement new
Но это работает только если конструктор существует. Если он удален — такой способ тоже не пройдёт.
Решение 3: Использование std::launder и raw-памяти (без вызова конструктора)
Интереснее становится ситуация, когда мы используем char[]
для хранения памяти и интерпретируем её как нужный тип с помощью std::launder
.
std::launder
необходим, начиная с C++17, чтобы помочь компилятору правильно обрабатывать aliasing и оптимизации.
Пример:
alignas(A) char data[sizeof(A)];
A* a = std::launder(reinterpret_cast<A*>(data));
Важно: мы не создаём объект в строгом смысле. Это UB по стандарту, если объект не был создан явно через placement new или иной способом, который инициализирует объект. Но если наш тип POD (Plain Old Data), то такое поведение часто работает в реальных реализациях.
Реализация pod_storage: безопасное хранение POD-типов без конструкторов
Мы можем создать шаблонную обёртку, которая обеспечивает доступ к raw-памяти, интерпретируя её как объект нужного типа:
template <typename T>
class pod_storage {
alignas(T) char data_[sizeof(T)];
public:
T* get() noexcept {
return std::launder(reinterpret_cast<T*>(data_));
}
const T* get() const noexcept {
return std::launder(reinterpret_cast<const T*>(data_));
}
T* operator->() noexcept { return get(); }
const T* operator->() const noexcept { return get(); }
T& operator*() noexcept { return *get(); }
const T& operator*() const noexcept { return *get(); }
};
Это позволяет нам работать с типами, у которых нет конструктора.
Расширение идеи: Перегрузка операторов, обнуление памяти, поддержка статического и динамического массива с пользовательским аллокатором
#include <iostream>
#include <memory>
#include <type_traits>
#include <cstddef>
#include <cstring>
#include <vector>
// ==============================
// 1. pod_storage — хранение одного объекта без конструктора
template <typename T>
class pod_storage {
alignas(T) std::byte data_[sizeof(T)];
public:
pod_storage() noexcept {
std::memset(data_, 0, sizeof(data_));
}
T* get() noexcept {
return std::launder(reinterpret_cast<T*>(data_));
}
const T* get() const noexcept {
return std::launder(reinterpret_cast<const T*>(data_));
}
T* operator->() noexcept { return get(); }
const T* operator->() const noexcept { return get(); }
T& operator*() noexcept { return *get(); }
const T& operator*() const noexcept { return *get(); }
};
// ==============================
// 2. pod_array — статический массив без конструкторов
template <typename T, std::size_t N>
class pod_array {
alignas(T) std::byte data_[N * sizeof(T)];
public:
pod_array() noexcept {
std::memset(data_, 0, sizeof(data_));
}
T* data() noexcept {
return std::launder(reinterpret_cast<T*>(data_));
}
const T* data() const noexcept {
return std::launder(reinterpret_cast<const T*>(data_));
}
T& operator[](std::size_t i) noexcept {
return data()[i];
}
const T& operator[](std::size_t i) const noexcept {
return data()[i];
}
static constexpr std::size_t size() noexcept {
return N;
}
};
// ==============================
// 3. Пользовательский аллокатор
template <typename T>
class pod_allocator {
public:
using value_type = T;
pod_allocator() = default;
template <typename U>
pod_allocator(const pod_allocator<U>&) noexcept {}
[[nodiscard]] T* allocate(std::size_t n) {
void* ptr = std::malloc(n * sizeof(T));
if (!ptr) throw std::bad_alloc();
return static_cast<T*>(ptr);
}
void deallocate(T* ptr, std::size_t /*n*/) noexcept {
std::free(ptr);
}
};
template <typename T, typename U>
constexpr bool operator==(const pod_allocator<T>&, const pod_allocator<U>&) noexcept {
return true;
}
template <typename T, typename U>
constexpr bool operator!=(const pod_allocator<T>& lhs, const pod_allocator<U>& rhs) noexcept {
return !(lhs == rhs);
}
// ==============================
// 4. dynamic_pod_array — динамический массив с аллокатором
template <typename T, typename Allocator = std::allocator<T>>
class dynamic_pod_array {
private:
T* data_;
std::size_t size_;
Allocator alloc_; // Используем пользовательский аллокатор
public:
explicit dynamic_pod_array(std::size_t n)
: size_(n), data_(alloc_.allocate(n)) {
std::memset(data_, 0, n * sizeof(T));
}
~dynamic_pod_array() {
// Не вызываем деструкторы (POD)
alloc_.deallocate(data_, size_);
}
T* data() noexcept { return data_; }
const T* data() const noexcept { return data_; }
T& operator[](std::size_t i) noexcept {
return data()[i];
}
const T& operator[](std::size_t i) const noexcept {
return data()[i];
}
[[nodiscard]] std::size_t size() const noexcept {
return size_;
}
[[nodiscard]] const Allocator& get_allocator() const noexcept {
return alloc_;
}
};
struct Point {
int x, y;
Point() = delete; // Конструктор удален
};
int main() {
{
// Используем pod_storage
pod_storage<Point> PointStorage;
// Работаем через operator->
PointStorage->x = 42;
std::cout << "PointStorage->x = " << PointStorage->x << '\n';
// Работаем через operator*
std::cout << "(*PointStorage).x = " << (*PointStorage).x << '\n';
// Можно получить указатель напрямую
Point* p = PointStorage.get();
p->x = 100;
std::cout << "p->x = " << p->x << '\n';
}
// Используем pod_array
{
pod_array<Point, 3> points;
points[0] = { 1, 2 };
points[1] = { 3, 4 };
points[2] = { 5, 6 };
for (size_t i = 0; i < points.size(); ++i) {
std::cout << "pod_array[" << i << "] = {"
<< points[i].x << ", " << points[i].y << "}\n";
}
}
// Используем std::allocator через dynamic_pod_array
{
dynamic_pod_array<int> arr(4);
for (size_t i = 0; i < arr.size(); ++i) {
arr[i] = static_cast<int>(i * 10);
}
std::cout << "Using std::allocator:\n";
for (size_t i = 0; i < arr.size(); ++i) {
std::cout << "arr[" << i << "] = " << arr[i] << '\n';
}
}
// Используем pod_allocator
{
dynamic_pod_array<Point, pod_allocator<Point>> arr(3);
arr[0] = {1, 2};
arr[1] = {3, 4};
arr[2] = {5, 6};
std::cout << "Using pod_allocator:\n";
for (size_t i = 0; i < arr.size(); ++i) {
std::cout << "Point[" << i << "] = {"
<< arr[i].x << ", " << arr[i].y << "}\n";
}
}
return 0;
}
Ссылка на полный код
Полный код тут: QbitQuantum/trivial_storage
Заключение
Программирование — это не только про то, как решить задачу, но и про то, чтобы попробовать сделать то, что «нельзя». Порой самые интересные решения рождаются именно там, где стандартные инструменты заканчиваются.
И если нельзя — не значит невозможно. Просто нужно найти свой путь.
Комментарии (18)
ZirakZigil
20.05.2025 07:41std::launder(static_cast<T *>(std::memmove(ptr, ptr, sizeof(T))));
В 17 тоже самое через memcpy между массивом байтов/чаров. И без UB.
eao197
20.05.2025 07:41Думается, у вас ошибка в тексте:
Важно: мы не создаём объект в строгом смысле. Это UB по стандарту, если объект не был создан явно через placement new или иной способом, который инициализирует объект. Но если наш тип POD (Plain Old Data), то такое поведение часто работает в реальных реализациях.
Вместо часто работает должно было бы быть пока еще работает
После добавления в язык std::start_lifetime_as у компиляторщиков развязаны руки для того, чтобы начать эксплуатировать UB, на котором построен ваш код.
1QDenisQ Автор
20.05.2025 07:41Вы правы, этот код потенциально опирается на поведение, не гарантированное стандартом. Однако важно учитывать контекст: статья рассматривает практики, применимые в C++17 , где ещё не было
std::start_lifetime_as
, и где компиляторы действительно трактовали такие конструкции как рабочие.В данной статье рассматриваются методы создания объектов без использования конструктора по умолчанию с использованием возможностей стандарта C++17 , который предоставляет гибкие инструменты управления памятью и типобезопасностью
Да, с точки зрения современных стандартов (например, C++20 и новее), многие практики из C++17 могут считаться потенциально некорректными. Но если речь о реальности C++17 — такие подходы остаются рабочими
eao197
20.05.2025 07:41Но если речь о реальности C++17 — такие подходы остаются рабочими
Формально они-то как раз и не рабочие. И никто не даст вам гарантии, что условный clang-25, который начнет эксплуатировать данный UB, оставит старое поведение для C++17. Т.е. если кому-то взбредет в голову использовать описанный вами подход в своем коде, то это будет похоже на закладку мины замедленного действия. Работает, работает, о том, как оно сделано, уже никто и не знает, т.к. автор ушел в закат пару лет назад. В один прекрасный момент случается обновление компилятора и разбирайся потом почему перестало работать то, что работало.
Так что не вводите в заблуждение читателей. То, что вы описываете -- это хак, который пока что работает, т.к. до C++17 не было std::launder и компиляторщики не позволяли себе эксплуатировать этот UB. После C++20 и C++23 руки у них развязаны. И хак этот не имеет отношения к " гибким инструментам управления памятью и типобезопасностью".
1QDenisQ Автор
20.05.2025 07:41Вы правы: описанный подход формально является неопределённым поведением согласно стандарту C++. Мы не создаём объект в строгом смысле — мы просто интерпретируем сырую память как объект. Это UB, и это нельзя игнорировать.
Однако цель статьи — не дать рекомендации к использованию в production-коде, а показать, как устроена работа с памятью «под капотом» , и какие техники применяются в определённых нишевых задачах: сериализация, memory-mapped I/O, реализация собственных контейнеров, low-level оптимизации.
Программирование — это не только про то, как решить задачу, но и про то, чтобы попробовать сделать то, что «нельзя». Порой самые интересные решения рождаются именно там, где стандартные инструменты заканчиваются. И C++ — это язык, который позволяет заглянуть за эти границы.
Фраза про "работает в C++17" — это констатация факта, а не призыв использовать такой код везде. Да, современные компиляторы могут начать активно эксплуатировать такое UB, особенно с появлением
std::start_lifetime_as
. Это действительно может привести к внезапным багам после обновления компилятора — вы абсолютно правы.Но если обновился компилятор, и код перестал работать — это проблема разработчика , а не языка. Если он выбрал путь работы с raw-памятью, значит, он должен понимать, что делает, и быть готов к последствиям. В C++ вам дают пистолет. А уже вам решать — стрелять ли из него, и куда. Компилятор же лишь периодически меняет направление ствола, пока вы не глядя жмёте на спусковой крючок.
Так что пусть статья будет не руководством к действию, а скорее картой минного поля — чтобы те, кто всё равно решится пройти этим путём, хотя бы знали, где они идут.
И не стоит меня обвинять за то, что кто-то применит этот материал не по назначению. Как не обвиняют создателя пистолета в том, что вы себе отстрелили ногу. Это ваш выбор. И ваша ответственность
eao197
20.05.2025 07:41как устроена работа с памятью «под капотом»
Простите за прямоту, но этого в статье нет от слова совсем.
И не стоит меня обвинять за то, что кто-то применит этот материал не по назначению
Стоит, поскольку в статье нет достаточного количества предупреждений о том, чем это может потенциально грозить. Как и нет рекомендаций о том, чем следует заменить этот хак опираясь на свежие стандарты.
1QDenisQ Автор
20.05.2025 07:41Вы абсолютно правы — в статье действительно не хватает важных предупреждений о рисках использования описанного подхода, а также ссылок на современные и безопасные альтернативы. Это упущение с моей стороны.
Что до вопросов ответственности — в следующих материалах я обязательно это учту.
Что касается дальнейшей дискуссии — считаю, что мы сейчас выходим за рамки программирования и технической части статьи, поэтому давайте оставим разговор здесь. Ещё раз благодарю за замечание — оно поможет сделать материал лучше.
eao197
20.05.2025 07:41Если говорить о технической составляющей, то применение 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 }
Apoheliy
20.05.2025 07:41Насколько понял, идеи, использованные в статье используются (и имеют смысл) только для pod (можно даже сузить до trivially_copyable) типов.
И тогда предлагаемое решение выглядит очень уж замороченным для "условной" десериализации данных.
По-моему, здесь напрашивается вариант с созданием экземпляра через один из штатных способов (конструктор с параметрами/фабрика и др.) и потом делаем копирование через memcpy или вычитывание из файла. И если тип is_trivially_copyable, то это штатно разрешено. В ином случае - вам это не нужно.
Да, получается немного больше работы: создал, потом скопировал. Но это уже на совести разработчика типа данных, который всё запретил.
---
Также согласен с комментариями выше: закладывать мину замедленного действия, которая может позже сработать - это очень нехорошо. Не надо так делать!
Notevil
20.05.2025 07:41У меня вопрос про конструкцию
alignas(A) char data[sizeof(A)]; A* a = std::launder(reinterpret_cast<A*>(data));
Если
A
не POD тип? Если он наследуется от какого-тоBase
который имеет свои поля, который наследуется от некоегоIAbstract
у которого есть виртуальные методы реализуемые вA
. Какие могут быть проблемы в c++17?
vilner61
20.05.2025 07:41У вас контейнер не удовлетворяет требованиям AllocatorAwareContainer и непонятно зачем вы прикрутили аллокатор в качестве шаблонного аргумента. С другими аллокатором может не сработать, особенно со statefull аллокаторами ,полиморфными аллокаторами как их частным случаем и std::scoped_allocator_adaptor. Будут очень сложно обнаруживаемые ошибки в стандартных контейнерах , которые содержат ваш контейнер как тип связанные с пропогацией аллокаторов. И другие веселости связанные с пропагацией аллокаторов.Попробуйте прокинуть все через allocator traits. И переопределить assignment операторы и copy конструктор для соответствующих вариантов пропагации аллокаторов.
И еще лучше вставить явный static assert на тип шаблонного аргумента контейнера , что он является pod.
1QDenisQ Автор
20.05.2025 07:41Моя цель — сохранить концепцию 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
unreal_undead2
То зачем удалять его (по факту пустой) конструктор?
1QDenisQ Автор
Иногда намеренно удаляют конструктор по умолчанию, даже если он тривиальный — например, у простых структур (POD), которые содержат только данные. Это делается не потому, что конструктор вреден, а чтобы запретить создавать объекты случайно или без явной инициализации .
Допустим есть структура
Point { int x; int y; }
. Можно создать её так:Point p;
Но тогда поля
x
иy
будут иметь неопределённые значения. Это может привести к багам, если этот объект представляет с собой объект в пространстве для какой-то системы. Для это и удаляют конструктор по умолчаниюunreal_undead2
С таким вариантом согласен - но тогда получается, что сначала явно (и по делу) запрещаем, а потом героически преодолеваем это запрет. Какой смысл в такой архитектуре в целом? Или речь о том достаточно локализованном коде, который предоставляет интерфейс для создания таких объектов?
1QDenisQ Автор
Цель статьи — не навязывать стиль программирования или подходы, а показать, как работают механизмы языка в нестандартных ситуациях. Это скорее технический эксперимент и разбор того, что возможно в C++, когда хочется сделать то, что "по документации нельзя". Ваш комментарий абсолютно верный — в массовом применении такие трюки могут быть избыточными или даже вредными. Но в узких, специфических задачах они могут дать полезное понимание устройства языка и памяти.
Согласен, если просто запрещать конструкторы, а потом их героически обходить — это похоже на игру в кошки-мышки.
Но цель статьи — показать не «как надо», а «что возможно». Это скорее исследование, чем рекомендация.
То есть, это не про лучшие практики для повседневного кода, а про то, как устроен язык изнутри, и какие возможности он даёт тем, кто хочет заглянуть за границы привычного.