В 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)


  1. unreal_undead2
    20.05.2025 07:41

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

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


    1. 1QDenisQ Автор
      20.05.2025 07:41

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

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

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


      1. unreal_undead2
        20.05.2025 07:41

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


        1. 1QDenisQ Автор
          20.05.2025 07:41

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

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

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

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


  1. ZirakZigil
    20.05.2025 07:41

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

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


  1. KanuTaH
    20.05.2025 07:41

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


  1. eao197
    20.05.2025 07:41

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

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

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

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


    1. 1QDenisQ Автор
      20.05.2025 07:41

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

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

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


      1. eao197
        20.05.2025 07:41

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

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

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


        1. 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++ вам дают пистолет. А уже вам решать — стрелять ли из него, и куда. Компилятор же лишь периодически меняет направление ствола, пока вы не глядя жмёте на спусковой крючок.

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

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


          1. eao197
            20.05.2025 07:41

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

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

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

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


            1. 1QDenisQ Автор
              20.05.2025 07:41

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

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

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


              1. 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
                }
                


  1. Apoheliy
    20.05.2025 07:41

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

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

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

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

    ---

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


  1. 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?


    1. BorisU
      20.05.2025 07:41

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


  1. vilner61
    20.05.2025 07:41

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

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


    1. 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