Мотивация писать такие большие статьи бывает самая причудливая. Я вот, например, сидел и писал совершенно другую статью: Как написать свой std::function. И в какой-то момент поймал себя на том, что не в полной мере уверен, могу ли я перед читателем честно отстоять некоторые моменты в коде.
И вроде бы все работало, и компилятор выдавал мне рабочее решение, но меня сопровождало чувство, которое нередко посещало меня за написанием C++-кода: “Я не знаю, каноничный ли у меня C++”. И вроде бы внутренне ты понимаешь, что все хорошо, но на деле ты даже никогда не заглядывал в стандарт самостоятельно, чтобы знать наверняка. В какой-то момент я стал сомневаться во многих своих знаниях и практиках, влоть до паранойи.
Что любопытно - все сомнительные места казались разрозненными, несвязанными случаями: корректно ли я использую reinterpret_cast, вызовется ли деструктор, нужен ли мне std::launder, насколько легальны те или иные низкоуровневые трюки с памятью. Только после начала пробных попыток разобраться до меня дошло: все-все это крутится вокруг времени жизни объекта - обширнейшей темы, которую едва кто-то может нормально объяснить - и если я охвачу эту область сполна, я получу в руки ключ от C++.
Получить этот ключ тяжело - от того и статья сложная. Но вы сможете.
С
Для разминки начнем с языка C. В нем создание объекта типа T - это когда вы выделили достаточное количество памяти через malloc и стали интерпретировать ее как указатель на T. Все - пользуйтесь объектом на здоровье. То же самое с уничтожением объекта: вызываем free, и объекта не стало. Это мы говорили про кучу. Со стеком вам не нужны и эти кульбиты.
Основная идея, которую я хочу здесь заложить - с точки зрения языка объект готов к немедленному использованию сразу после выделения под него байт.
struct Point { int x; int y; }; struct Point* p = malloc(sizeof(*p)); // все - можно пользоваться! p->x = 10;
malloc возвращает void* - просто сырые байты. Вы сами кастите их в Point* и тут же начинаете пользоваться объектом. И все будет отлично, если вы выделили достаточное количество памяти под объект.
Конечно, в ваши объекты может быть заложена такая логика, что объектом нельзя пользоваться без предварительной ручной инициализации. Например, для установки необходимых инвариантов. Но это уже история не про язык, а про бизнес-логику ваших классов.
C++: new и delete
Теперь C++. Здесь объекты создаются в две фазы: сначала выделяется память под объект, затем вызывается конструктор объекта. Аналогично с уничтожением, но в обратном порядке: сначала вызывается деструктор, затем освобождается память. В стандарте нет четкой терминологии, которая бы могла обозвать комбинации двух фаз целиком, поэтому я введу свою терминологию, которая будет использоваться в статье:
Создание объекта = выделение памяти + конструирование объекта Удаление объекта = разрушение объекта + освобождение памяти
По-английски:
Object creation = memory allocation + object construction Object deletion = object destruction + memory deallocation
Я считаю эту терминологию удачной, так как сразу становится видно, в какой момент времени отрабатывают constructor и destructor.
Известные всем new и delete на самом деле называются new expression и delete expression - это важно, поскольку есть другие new и delete - и они выполняют сразу обе фазы создания/уничтожения. Т.е. они за вас поработают с системным аллокатором памяти и сами вызовут конструктор/деструктор.
Используем new expression для создания объекта:

Используем delete expression для удаления объекта:

Так как это наиболее распространенные способы создать объект на куче, и они отлично абстрагируют от вышеупомянутой “двух-фазности”, многие программисты, включая меня, могут годами не подозревать, что данная двухфазность вообще существует. А тем не менее она есть и начинает явно проявлять себя там, где живут аллокаторы, in-place storage оптимизации, контрольные блоки std::shared_ptr и прочее. И здесь у неподготовленного программиста начинает рваться шаблон, и случается типовое “сколько ни учу C++, а он как бездонный”.
Ручное управление жизнью объекта
Если вы хотите руками провести обе фазы создания объекта, вы:
Выделяете память под объект
Конструируете объект по адресу, заранее полученному из первой фазы

На схеме выше память была выделена через operator new - заметьте, что это не new expression - это другой зверь, родственный malloc - он возвращает сырые байты. Но так же вы можете выделить память под объект и другими способами: использовать свой аллокатор или взять кусок места из статической памяти или даже со стека, как это делается в SSO-оптимизациях
Теперь, как сконструировать объект в аллоцированной ранее памяти? По сути мы хотим вызвать конструктор у объекта, но сделать это по конкретному заранее известному адресу. Это нельзя сделать через простое Point p(10, 15); - здесь нужны узкоспециализированные инструменты. Судя по всему до C++20 единственной возможностью сделать это был и остается placement new. Начиная с C++20 можно и нужно пользоваться std::construct_at - эта функция оградит вас от откровенно плохого синтаксиса placement new, и к тому же более явно выражает намерение; т.е. std::construct_at удобнее и с точки зрения пишущего и с точки зрения читающего. Функция не делает ничего особенного - просто заметает вызов placement new под ковер.
Для двухфазного уничтожения объекта вы:
Уничтожаете объект. Мы хотим, чтобы отработал деструктор
Высвобождаете память

Фаза уничтожения объекта. Это тот редкий случай, когда деструктор вызывается руками. Начиная со стандарта C++17 есть возможность сделать то же самое через функцию std::destroy_at, которая составляет симметричную пару функции std::construct_at и имеет такие же плюсы, как у нее. Под капотом все тот же ручной вызов деструктора.
И теперь мы дошли до момента высвобождения памяти. Здесь все обратно аллокации - из вариантов у вас: operator delete или, например, возврат памяти вашему аллокатору. Если память была выделена не из кучи, скорее всего вам вообще ничего не нужно делать - эта память сама с собой справится. Из важного - в подавляющем большинстве случаев деаллокация должна происходить из того же источника, из которого происходила аллокация. Т.е. если вы брали память через ::operator new, верните ее через ::operator delete; если через malloc - отдайте обратно через free; если брали у своего аллокатора, верните ему же.
Тут же стоит отметить, что есть случаи, когда освобождение памяти может делаться не совсем симметрично ее получению. К примеру, эта статья про аллокаторы описывает очень простой и эффективный Linear allocator, который отдает память по запросу, но не может освободить память конкретного объекта - он умеет только высвобождать всю свою память целиком отдельным методом reset. Например, на протяжении игрового кадра мы можем аллоцировать им память под объекты, а в конце кадра разом освободить всю память. Но это, конечно, нераспространенный подход - обычно все же деаллокация симметрична аллокации.
Отличие C++ -объектов от C -объектов
Рассмотрим создание и уничтожение объектов на примере обычного C++-класса:
struct Foo { const char* name; Foo(const char* name_) : name(name_) { std::cout << name << ": start\n"; } ~Foo() { std::cout << name << ": die\n"; } };
Чем Foo отличается от любой C-структуры? В разрезе нашего обсуждения тем, что объект такого типа:
Нельзя сконструировать по умолчанию (can’t be default constructed), т.е. для его создания необходимо передать некоторую информацию. У
Fooнет дефолтного конструктора - только конструктор, принимающий параметр. Более того, сам конструктор выполняет некоторую логику, т.е. он должен быть вызван , чтобы программа работала корректноНельзя неявно уничтожить - деструктор обязан быть вызван, поскольку он тоже содержит какую-то логику
Иными словами, теперь мы совершенно никак не можем обойтись сишным подходом, когда аллокации памяти было достаточно, чтобы объект был неявно создан
Foo* t = static_cast<Foo*>(malloc(sizeof(Foo))); // 1 t->name = "foo"; // 2 free(t); // 3
1 хочу, чтоб как в Си
2 UB - объект не сконструирован; логирования не было
3 объект не разрушился; логирования не было
Мы так не можем хотя бы потому что у Foo в конструкторе и деструкторе содержится логика. В случае с Foo это его единственная логика, поскольку это классический пример RAII-класса. Явные стадии конструирования и разрушения объекта обязательны. Наша тестовая структура всего лишь логирует в стрим этапы своего жизненного цикла, но ведь классы могут полагаться в своих конструкторах и деструкторах на более серьезную логику, без отрабатывания которой программа просто перестанет корректно функционировать:
struct MutexGuard { std::mutex& m; MutexGuard(std::mutex& m_) : m(m_) { m.lock(); } ~MutexGuard() { m.unlock(); } }; struct Timer { using Clock = std::chrono::steady_clock; std::ostream& s; Clock::time_point start; Timer(std::ostream& s_) : s(s_) { start = Clock::now(); } ~Timer() { s << Clock::now() - start; } };
Рассмотрим сценарии корректного обращения с жизненным циклом Foo foo. Самый простой и распространенный сценарий - создание объекта на стеке:
{ Foo foo("foo"); // 1 ... } // 2
1 создаем объект: выделение памяти на стеке + конструирование
2 удаляем объект: уничтожение + освобождение места на стеке
Здесь невозможно ошибиться и выстрелить себе в ногу. Вызовутся и конструктор и деструктор, т.е. класс корректно создастся и корректно удалится. И заметьте, вы будете вынуждены передать в конструктор все необходимые параметры, поскольку конструктор по-умолчанию у класса отсутствует, и попытка сделать Foo foo; закончится синтаксической ошибкой.
Теперь выделение на куче стандартными new и delete:
Foo* foo = new Foo("foo"); // 1 delete foo; // 2
1 создаем объект: аллокация памяти в куче + конструирование
2 удаляем объект: уничтожение + деллокация
Полностью повторяется происходящее на стеке за исключением того, что теперь память выделяется на куче, а удаление объекта вызывается явно через delete вместо автоматического срабатывания по выходу из скоупа.
Теперь попробуем самостоятельно выделять память и конструировать объект. Например, представим, что new/delete expression нам не подходят, потому что мы хотим выделить память непременно через malloc/free:
Foo* t = static_cast<Foo*>(malloc(sizeof(Foo))); // 1 new (t) Foo("foo"); // 2 t->~Foo(); // 3 free(t); // 4
1 выделяем память
2 конструируем объект
3 уничтожаем объект
4 освобождаем память
Это доработанный “сишный” пример, но который верно работает для C++.
Память vs объект
Теперь я хочу вам показать примеры, по которым станет понятно, что может быть вообще ничего не понятно, если не знать наверняка, что и как должно происходить. Что время жизни объекта и память, в которой он живет - очень хитрая и сложная вещь в C++. Ничего страшного, если вы не будете видеть в происходящем логику или единообразность, я бы даже сказал, что это нормально.
Первый пример:
Foo* t = static_cast<Foo*>(malloc(sizeof(Foo))); new (t) Foo("foo"); // ┓ // ┃ 1 t->~Foo(); // ┛ free(t);
Время жизни объекта обозначено . Вне этого диапазона объекта не существует - есть лишь память, выделенная под него.
Теперь давайте посмотрим на более интересный пример - объявим переменную на стеке:
double d = 15.0;
Мы уже выяснили, что эта строка одновременно выделяет 8 байт под d и “вселяет жизнь” в эту память.
Теперь делаем так:
double d = 15.0; new (&d) int(4);
Как вы думаете, насколько это легальный код, и что произошло? На самом деле, он вполне легальный. Посмотрим на время жизни объектов double и int.
{ double d = 15.0; // ┓ // ┃ 1 new (&d) int(4); // ┫ // ┃ 2 } // ┛
1 время жизни объекта
double2 время жизни объекта
int1 + 2 время, на которое на стеке было аллоцировано 8 байт
Итого: два объекта живут в одной памяти в разное время.
Пример валиден только для платформ, где
sizeof(double) >= sizeof(int)иalignof(double) % alignof(int) == 0
Заметьте, что объект int скорее всего займет меньше байт, чем доступно в выделенной под double памяти. Но главное, что он помещается.
Похожий пример:
using Bytes = std::byte[sizeof(Point)]; ... alignas(Point) Bytes data; Point* p = new (data) Point; p->~Point();
В предыдущем примере мы размещали int “в теле” double, теперь размещаем Point в массиве байт - по сути схожее действие. Смотрим на время жизни объектов:
{ alignas(Point) Bytes data; // ┓ Point* p = new (data) Point; // ┓ ┃ // ┃1 ┃2 p->~Point(); // ┛ ┃ // ┃ } // ┛
1 время жизни объекта
Point2 время жизни объекта
std::byte[]. Так же это время, на которое на стеке выделеноsizeof(Point)байт, в которых происходит все действие
Теперь “объект-оболочка” интересным образом не закончил время своей жизни после размещения объекта Point в его теле - это расходится с тем, что мы видели а предыдущем примере, хотя казалось бы, какая разница.
Еще один пример:
struct Point { int x, y; }; struct Line { Point p1, p2; }; { Line l; // ┓ ... // ┃ 1 } // ┛
1 время жизни семи объектов сразу: одного
Line, двухPoint, четырехint. Так же это время жизни одного единственного куска памяти, в котором все эти объекты располагаются, частично перекрывая друг друга
Неожиданный ракурс - я сам, когда создавал пример, не думал, что насчитаю целых семь объектов. При этом, данный пример нам более-менее понятен, потому что это естественно, что объект-агрегат и вложенные в него объекты живут одновременно. Они, как и в предыдущем примере, делят одну и ту же память и живут в ней одновременно:
0x0 ┓i ┓P ┓ ┃n ┃o ┃ ┛t ┃i ┃ 0x4 ┓i ┃n ┃ ┃n ┃t ┃L ┛t ┛ ┃i 0x8 ┓i ┓P ┃n ┃n ┃o ┃e ┛t ┃i ┃ 0xC ┓i ┃n ┃ ┃n ┃t ┃ ┛t ┛ ┛
Итого, что мы имеем:
Время жизни объекта не обязательно совпадает со временем жизни выделенной памяти
Одну и ту же память в один момент времени может занимать несколько объектов сразу
Но бывают случаи, когда создание одного объекта в памяти уничтожает объект, размещенный там ранее
Объект не обязательно занимает всю выделенную память - он может занимать лишь часть
Теперь, я думаю, вам ясно видно - тема совершенно непростая, и мы ее даже толком не начали - так, бегло посмотрели на вершину огромного айсберга. Более того, я утверждаю, что хорошее понимание времени жизни объектов в C++ - ключ к пониманию языка. Добрая часть витиеватых правил стандарта пляшет вокруг времени жизни объектов. Добрая горсть UB рассыпана из-за правил времени жизни объектов.
И вот теперь мы потихоньку начнем разбираться…
Перед погружением
Чтобы разношерстные правила стандарта не вызывали у вас негодования, очень полезно заранее знать ответы на вопросы: зачем так сложно, почему так сложно и ради чего так сложно.
В языке C++ существует две фундаментально противоречащие друг другу силы:
компиляторы, которые хотят агрессивно оптимизировать
программисты, которые пишут низкоуровневый код
Казалось бы - у них схожие задачи: генерация наиболее производительного машинного кода. Но они пытаются достичь этого разными способами.
Компилятор хочет как можно шире трактовать поведение вне времени жизни объекта как невозможное - это дает ему право выбрасывать “мертвые” обращения, переиспользовать память под стеком и не перепроверять, какой объект сейчас лежит по данному адресу. Программист же намеренно переиспользует память для эффективности - placement-new поверх старого объекта, union, аллокаторы, буферы - то есть постоянно оказывается ровно там, где компилятор ожидает UB. Многие трюки пришли еще из Си, и в целом видятся программистам естественными.
Стандарт не жертвует ни одной из сторон, а вместо этого пытается лавировать. Поэтому правил так много: каждое исключение - отдельный компромисс между конкретным низкоуровневым паттерном, который индустрия использует, и конкретной оптимизацией, которую компилятор не готов терять. В итоге имеем, что имеем.
Далее я буду активно цитировать стандарт. Но стоит понимать, что стандарт C++ - живой организм, и меняется все время. На момент написания статьи я опирался на материал eel.is, где публикуется текущий актуальный черновик. Приведенные мной параграфы актуальны на 28 сентября 2026 года, но со временем их содержание и формулировки будут неизбежно меняться. В частности будут меняться номера самих параграфов, поэтому я решил показывать вам параграфы в таком виде:
Определение скаляров
[basic.types.general] p7
Arithmetic types, enumeration types, pointer types, pointer-to-member types,
std::meta::info,std::nullptr_t, and cv-qualified versions of these types are collectively called scalar types. …
К слову я не зря выбрал именно этот пример - определение скаляров нам понадобится не один раз! Так что запоминайте сразу :)
Первым идет придуманное мной название параграфа, которое я буду использовать, если в тексте придется повторно на него ссылаться. [basic.types.general] - так называемый stable name, призванный оставаться более-менее неизменным. Это своего рода аналог главы в книге, состоящей из множества параграфов. p7 - номер параграфа, и это, напротив, крайне волатильная сущность. Параграф однозначно определяется связкой stable name + номер параграфа: [basic.types.general] p7. Я буду ссылаться на такой номер один раз при первом обращении к параграфу, а при повторных обращениях буду использовать мое условное именование. Так статью будет проще поддерживать с течением времени.
Иногда я буду ссылаться на исторические параграфы из прошлого, из конкретной версии стандарта. В таком случае я буду ссылаться на них так: C++17 [expr.pseudo] p1. Без придуманного имени.
Может показаться странным и оторванным от жизни то, что я привожу bleeding edge версию стандарта в качестве руководства к действию и показываю ее, как верную действительность. Все-таки многие из нас сидят на куда более ранних версиях стандарта. Например, геймдев, в частности и я сам, как представитель геймдева, поголовно сидим на C++17 и скорее всего не скоро пойдем дальше.
В этом есть некоторое рациональное зерно, но это лучшее, что я могу для вас сделать по ряду причин:
Объектная модель успела хорошо сформироваться к C++17. Вся статья крутится вокруг этой темы
Многие вещи заходят в стандарт как DR - defect report. Это когда стало ясно, что текущие или старые формулировки стандарта имеют вопиющие дыры, и их надо срочно идти латать. Компиляторы применяют DR ретроактивно. То есть ваш компилятор С++17 в 2к26 будет иметь все DR-патчи, какие он только смог в себя вобрать. Поэтому опираться на букву стандарта 2017 года попросту вреднее, нежели опираться на свежий драфт, который учел все дефекты. Вы просто не угадаете, какой параграф остался действительным, а какой уже не работает. Старые стандарты - это больше про историю
Статья не содержит рекомендаций, соблюдение которых в старых стандартах принесло бы вам UB или что-то сломало (напишите мне, если найдете - это важный момент)
Со временем статья станет не такой уж cutting edge. Как раз сможем проверить, как изменится стандарт, и насколько активно придется поддерживать статью
Начнем.
Автоматически создаваемые объекты
В стандарте существует понятие implicit-lifetime types. Это типы, жизненный цикл которых может начаться автоматически - без явного вызова конструктора или placement new - при определенных обстоятельствах. Стандарт приводит хитрый список типов, которые он относит к таковым. Хитрый потому, что его нужно собирать из разных частей стандарта:
Определение implicit-lifetime типов
[class.prop] p8
A class S is an implicit-lifetime class if
it is an aggregate whose destructor is not user-provided or
it has at least one trivial eligible constructor and a trivial, non-deleted destructor.
[basic types.general] p7
Scalar types, implicit-lifetime class types, array types, and cv-qualified versions of these types are collectively called implicit-lifetime types.
В итоге получаем сводный список implicit-lifetime типов:
Классы-аггрегаты без пользовательского деструктора
Классы с тривиальным деструктором и хотя бы одним тривиальным конструктором
Скалярные типы
Массивы
Начиная с C++23 такие типы можно определить функцией std::is_implicit_lifetime. В стандарте есть параграфы, описывающие, в каком контексте implicit-lifetime типы могут использоваться:
Implicit-lifetime функции
[intro.object] p16, Note 6
Some functions in the C++ standard library implicitly create objects ([obj.lifetime], [c.malloc], [mem.res.public], [bit.cast], [cstring.syn])
[intro.object] p13
… For each operation that is specified as implicitly creating objects, that operation implicitly creates and starts the lifetime of zero or more objects of implicit-lifetime types in its specified region of storage if doing so would result in the program having defined behavior. … [Note 4: Such operations do not start the lifetimes of subobjects of such objects that are not themselves of implicit-lifetime types. — end note]
Упомянутые референсы ведут на следующие функции/методы:
malloc,calloc,aligned_alloc::operator newstd::pmr::memory_resource::allocatememcpy,memmovebit_caststart_lifetime_as
Это и есть те самые определенные обстоятельства - т.е. это всегда вызов какой-то особенной для стандарта функции.
Рассмотрим типовые сценарии.
Аллокация памяти
struct Point { int x; int y; }; Point* p = static_cast<Point*>(malloc(sizeof(*p))); // все - можно пользоваться! p->x = 10;
ОПА! Это же наш пример Си-кода из начала статьи. То есть все-таки так можно и в C++, ура! А немного оглянувшись, понимаешь, что даже чтобы обеспечить совместимость с Си стандарту C++ приходится идти на исключения из собственных правил. Ведь в общем случае мы должны явно сконструировать объект! Но не в этот раз. Поскольку Point - implicit-lifetime type, вызов malloc спровоцирует создание объекта Point в памяти, на которую указывает Point* p.
Фактически любые аллоцирующие память библиотечные функции умеют неявно создавать объект.
Десериализация данных
Вам пришли сырые байты по сети. Хотите восстановить полученный объект.
Вот так поступить нельзя:
std::byte* b = /*добыли сырые байты*/; Point* obj = reinterpret_cast<Point*>(b); obj->x = 15; // UB!
В этих байтах, в этой памяти никогда не существовало и не существует объекта Point. reinterpret_cast не создает в ней объект - у него другая цель - про этот каст мы еще поговорим. Следовательно интерпретация байт как Point - UB, и компилятор вправе наказать вас за это в рантайме.
Теперь рассмотрим варианты, как можно.
C++23 дает самый логичный и адекватный способ:
std::byte* b = /*добыли сырые байты*/; Point* obj = std::start_lifetime_as<Point>(b); obj->x = 15; // OK
Просто вдохнули жизнь в память и получили вожделенный указатель.
Но не только лишь все могут использовать C++23, а потому в более близком к нынешнему продакшену коде можно встретить другие техники.
Мы можем скопировать байты с помощью memcpy:
std::byte* b = /*добыли сырые байты*/; std::byte* obj = /*у нас есть место под объект*/; std::memcpy(obj, b, sizeof(Point)); Point* p = reinterpret_cast<Point*>(obj); p->x = 15; // OK
Вы можете справедливо сказать: если мы как-то выделили память в obj, значит объект мог неявно создаться еще на этой стадии? Ответ: не обязательно - память не обязательно выделяется с помощью new или malloc, которые тоже умеют неявно создавать объект. Представьте, что это, внезапно, память со стека - какой-нибудь std::byte[sizeof(Point)], или даже статическая память. Просто я взял на нее указатель. В таком случае объект Point неявно создастся именно на стадии std::memcpy.
Есть и более курьезный вариант:
std::byte* b = /*добыли сырые байты*/; std::memmove(b, b, sizeof(Point)); Point* obj = reinterpret_cast<Point*>(b); obj->x = 15;
Как вам такое? Копирование в самого себя компилятор, вероятно, соптимизирует, но создать объект Point будет обязан.
Примечание: когда речь заходит о
memcpyиmemmove, обычно говорят о trivially copyable типах, но о них мы тоже поговорим. Сейчас ограничимся тем, что нашPointи так одновременно и implicit-lifetime и trivially copyable тип.
Странности неявного создания объекта
Есть один момент, который стоит отметить. Посмотрите на эти примеры:
void* raw = std::malloc(8); // 1 Point* p = static_cast<Point*>(raw); ... std::byte buf[8]; std::memcpy(buf, p, 8); // 2 Point* p2 = reinterpret_cast<Point*>(buf);
1 и 2 - строки, где происходит неявное создание объекта
Point. Вам ничего не кажется странным? Обе строки не имеют и намека на тип, который нужно неявно создать. Только на стадии каста мы явно выражаем свое намерение и раскрываем карты, какой тип мы бы хотели получить. Но каст ничего не создает! Создают объект именноstd::mallocиstd::memcpy.
Стандарт не зря так хитро сформулировал свое правило:
… operation implicitly creates and starts the lifetime of zero or more objects of implicit-lifetime types in its specified region of storage if doing so would result in the program having defined behavior
Грубо говоря компилятор заставляют анализировать окружающий контекст и подставлять тип создаваемого объекта задним числом. Поэтому хронология событий может немного гулять.
Как вы думаете, объект какого типа неявно создастся?:
struct A { int x; }; struct B { int x; }; alignas(A) std::byte storage[sizeof(A)]; std::memcpy(storage, data, sizeof(A)); B* p = reinterpret_cast<B*>(storage);
Ответ: B. Потому что неважно, как старательно мы делали вид, что память предназначена для A, если в итоге мы передумали и на момент каста решили, что это B.
Агрегаты
Интересно видеть среди implicit-lifetime типов классы-агрегаты. В целом ясна мотивация - вы можете материализовать целые блоки данных из сырых байт:
struct Header { uint32_t magic; uint16_t version; uint16_t flags; }; ... std::byte* b = /*добыли сырые байты*/; Header* h = std::start_lifetime_as<Header>(b);
Если внимательно читать параграф Implicit-lifetime функции, можно убедиться в том, что и все подобъекты - magic, version, flags - таким кодом тоже будут неявно созданы: “zero or more objects of implicit-lifetime types in its specified region of storage”.
Однако же подводные камни здесь существуют, и они существенные. Агрегатом без пользовательского деструктора может быть и такая структура:
struct S { int i; std::string str; };
И здесь возникают осложнения. Типы S и int по Определению implicit-lifetime типов является implicit-lifetime; но вот std::string - не является. Что в таких случаях происходит, прописано стандартом явно в последней ноте параграфа Implicit-lifetime функции: “Such operations do not start the lifetimes of subobjects of such objects that are not themselves of implicit-lifetime types.”.
Вот и получается - сам объект S сможет неявно создаться; вложенный S::i так же будет неявно создан; но S::str останется пустышкой без объекта.
std::byte* b = /*добыли сырые байты*/; S* s = std::start_lifetime_as<S>(b); s->i = 3; // 1 s->str.clear(); // 2
1 Все нормально - объект
s->iбыл создан2 UB - объекта
s->strнет
Что делать? Я знаю только одну опцию:
S* s = std::start_lifetime_as<S>(b); new (&s->str) std::string;
И вот теперь наш агрегат полностью населен живыми обитателями.
Эти трюки не для всех!
Не забывайте, что вышеперечисленные приемы относятся только к implicit-lifetime типам! Коих на самом деле не такое уж и большое количество. И в общем случае так не работает.
Выводы главы
Если
std::is_implicit_lifetime_v<T> == true, то объектTсоздастся при вызовеmalloc,::operator new,memcpy,std::start_lifetime_as. Явный вызов конструктора не обязателенЕсли
std::is_implicit_lifetime_v<T> == falseмагия перестает работать. В том числе и дляstd::start_lifetime_asБудьте осторожны с агрегатами. Неявное рекурсивное создание происходит только для тех членов, что сами являются implicit-lifetime типами. Очень легко получить объект с “дырами”: какие-то переменные живые, какие-то мертвые и требуют дополнительного ручного создания
Trivially copyable типы
Мы уже увидели, на что способен memcpy. Как видите, один факт его использования может вдохнуть жизнь в байты, в которых до этого не было жизни.
Однако же есть нюанс: чтобы иметь легальную возможность использовать memcpy или std::bit_cast для копирования объекта, нужно, чтобы он являлся trivially copyable типом. Такие типы можно определить функцией std::is_trivially_copyable.
Стандарт описывает такие типы как:
Определение trivially copyable
[class.prop] p1
A trivially copyable class is a class:
that has at least one eligible copy constructor, move constructor, copy assignment operator, or move assignment operator,
where each eligible copy constructor, move constructor, copy assignment operator, and move assignment operator is trivial, and
that has a trivial, non-deleted destructor.
Несмотря на лаконичное концентрированное описание, здесь просто запутаться, поэтому я перефразирую так, как мне понятнее - trivially copyable тип:
Имеет тривиальный деструктор
Среди copy/move-конструкторов и copy/move-операторов присваивания должна быть хотя бы одна не удаленная функция;
Все имеющиеся не удаленные copy/move-функции должны быть тривиальными.
Такой тип собрал джек-пот из тривиальных членов - это самый строгий к тривиальности трейт. И от этого он наиболее похож на “типы как в Си”, если говорить в разрезе того, что можно вытворять с побитовым представлением объектов этого типа. Самое главное разрешение, которое получают такие типы - возможность побитово копировать их объекты без нарушения целостности объекта. Т.е. вместо вызова конструктора копирования вы можете побитово скопировать объект .
Заметьте, что trivially copyable type - не то же самое, что implicit-lifetime type из предыдущей главы. Первое разрешает легально использовать memcpy для копирования объекта, второе позволяет неявно создать объект в буфере, в который memcpy копирует (с разрешения первого). Эти свойства пусть и очень сильно пересекаются, все же не тождественны и даже не имеют отношение parent-child, поскольку типы бывают:
-
Не trivially copyable, не implicit-lifetime
struct X { ~X { std::cout << "hi!"; } }; // нельзя побитово копировать, нельзя неявно создать -
Trivially copyable, implicit-lifetime
struct X { int i; }; // все можно -
Trivially copyable, не implicit-lifetime
struct X { X() : x(15) { } int x; }; // такой объект можно копировать побитово, // но это не создаст автоматически объект в буфере байт -
Не trivially copyable, implicit-lifetime
struct X { X() = default; X(const X& o) { counter = o.counter + 1; }; int counter; }; // копирование подразумевает сайд-эффекты - объект нельзя побитово копировать, // но неявно вселять в него жизнь - можно
Скалярные типы и простые структуры, рекурсивно состоящие из скалярных типов почти всегда являются и implicit-lifetime, и trivially copyable, поэтому эти трейты легко перепутать.
Выводы главы
trivially copyable типы можно копировать через
memcpyвместо вызова конструктора копированияtrivially copyable != implicit-lifetime, поэтому trivially copyable не всегда гарантирует вам автоматическое создание объекта в буфере, куда вы делаете
memcpy
Pointer provenance
Теперь зайдем к вопросу о времени жизни объектов с совсем другой стороны. Зададимся вопросом - что такое указатель? Или - какую информацию он несет?
Первейшее, что у нас ассоциируется с понятием указателя - его адрес. Просто потому что указатель неразрывно связан с памятью - буквально местом, где размещены данные, и указатель - наше знание об этом месте.
Второе, с чем ассоциируется указатель - тип. В записи T* у нас есть не только звездочка, но и упоминание T. И этот T - прямая инструкция, как нам интерпретировать данные, лежащие по адресу. int* и float*, указывающие на один адрес, будут по-разному интерпретировать данные, лежащие по адресу. И как покажут следующие главы, есть случаи когда, указатели могут валидно указывать на один и тот же адрес разными типами, и это “будет работать”, и не будет UB.
Еще в C++03 cвязка “адрес + тип” исчерпывающе описывала указатель как сущность. Стандарт изъяснялся так:
C++03 [basic.compound] p3
if an object of type
Tis located at addressA, a pointer of typeT*whose value is addressApoints to that object.
В C++11 и C++14 эти слова по прежнему оставались в стандарте, но в месте с тем стал проявляться новый, третий фактор, косвенно влияющий на указатель - объект и его время жизни. Правда, в этих версиях стандарта это понятие жило параллельно со связкой “адрес + тип” и не очень сильно претендовало на прямое влияние на идентичность указателя.
А потом пришел proposal P0137R1, вошедший в стандарт C++17. Бумага существенно переписала объектную модель в языке. Объекты и их время жизни стали занимать ключевую роль в поведении указателей - помимо адреса и типа теперь у указателя есть pointer provenance или “происхождение указателя”. Это мета-информация, которую компилятор хранит о каждом указателе, в первую очередь, чтобы на базе этих дополнительных данных строить свои предположения относительно живого (или не очень) объекта, который лежит под указателем. На базе этой информации компилятор может делать так называемые pointer optimizations: предположения о возможности или невозможности алиасинга конкретных указателей; трекинг объекта, на который указатель в данный момент указывает - нам более всего важен именно этот аспект. То есть получается, что теперь указатель это: “адрес + тип + объект под указателем”. Вы можете скастить указатель в указатель другого типа, но информация о объекте останется прежней - вы от нее не убежите.
Самое интересное, что в стандарте понятия pointer provenance так и не оказалось. Стандарт описывает хитрые правила времени жизни объектов и того, как указатели могут и не могут работать с этими объектами. Совокупность этих по крупицам собранных правил и обрисовывает необходимость компилятора следить за происхождением указателя и неявно рождает концепцию pointer provenance, которая существует в компиляторах и даже нередко фигурирует в пропоузалах в стандарт. Т.е. даже люди, максимально близкие к стандарту изъясняются термином pointer provenance, поскольку это удобный концепт, подразумевающий любую дополнительную мета-информацию о указателе. Я думаю, в какой-то момент он официально войдет в терминологию стандарта.
Мы тут, конечно, говорим про указатели, но важно понимать, что provenance применим не только к ним, но и к сущностям, которые неявно являются указателями на уровне, где компилятор оперирует адресацией памяти объектов. А это:
Указатели
Ссылки
Имена переменных - на стеке, статические или thread local
Указатель может быть ассоциирован с живым объектом, и тогда его разыменование приводит к беспроблемному доступу к объекту. Указатель может существовать и без живого объекта под ним, и это само по себе не является нарушением. Однако валидно распоряжаться таким указателем можно лишь с серьезными ограничениями: вы можете применять арифметику указателей, кастить его во что хотите, копировать, передавать, брать адрес; но как только вы попытаетесь распорядиться объектом (которого нет), вы вступаете на запретную территорию.
И вот, уже с пониманием этого, сразу становится видна проблема битовых трюков, которые любят практиковать любители низкоуровневого программирования:
float f = 529.4; // 1 int* p = reinterpret_cast<int*>(&f);// 2 int bytes = *p; // 3
1 За именем
fстоит живой объектfloat. Соответственно, указатель&fбудет иметь “благородное” происхождение - он указывает на живой объектfloat2 Каст указателя меняет его тип, но он не может стереть provenance - указатель изначально происходит от объекта
float. Более того - у нас в принципе не существует живого созданного объектаint. Мы его нигде не создавали; каст тоже никогда не создает объекты, даже для implicit-lifetime типов это происходит не через каст. То есть даже если как-то забытьfloat-происхождение, мы все равно не сможем ассоциироваться сint-объектом, поскольку его нет и никогда не было. Но при этом сам каст - еще не UB, так можно3 UB. Попытка обратиться к объекту не через тот тип. Такой доступ не разрешен правилами type-accessibility
И да, все эти трюки десятилетиями практикуются, а компиляторы в большинстве случаев позволяют так делать. Но у них есть право в любой момент создать в такой программе трудноуловимый баг, поскольку в какой-то частный момент компилятор найдет хороший шанс для оптимизации ровно по месту вашего хака. Такие дела.
Занимательная вещь, выводимая из provenance. Если у вас есть:
Указатель
T*с адресом0x1234Объект типа
T, живущий по адресу0x1234
это не означает, что ваш указатель ассоциирован с этим объектом. Т.е. компилятор с согласия стандарта может считать, что это не так и спекулировать на этом, как ему будет удобно.
Это странно и контринтуитивно, и блокирует у незнающих понимание некоторых важных концепций, на которые полагается стандарт языка. Ну а еще это полное перечеркивание того, что гарантировал стандарт C++03, но мы, знаете ли, не в 2003 году живем.
Чтобы грамотно усвоить концепцию, заложенную в C++17, нужно подготовить почву. Сейчас я опишу вам мое личное видение того, как указатели связываются с объектами в памяти. Это своего рода визуализация, которая помогает нормально понять, как это работает.
Представьте, что память - не одномерная лента с адресами, а двумерная матрица, где по оси Y мы имеем разные адреса в памяти, а по оси X у нас есть некоторое пространство, куда могут быть помещены объекты. Еще вместо оси X вы могли бы представить слои для одних и тех же адресов в памяти - оба подхода одинаково хорошо работают для мысленного эксперимента, но нарисовать проще двумерную матрицу. Выполним такой код:
T* t1 = new T;
Графически память будет выглядеть так:

Объект расположился по адресу 0x0A, указатель привязан к объекту и указывает прямо на него. Теперь уничтожим объект:
t1->~T();

Объекта больше нет, указатель же по прежнему существует, у него все такой же адрес 0x0A, но он указывает в никуда. Создадим новый объект по адресу 0x0A:
T* t2 = new (t1) T;

Посмотрите, какую интересную картину мы имеем. По адресу 0x0A расположился новый объект. И казалось бы - у указателя t1 тот же адрес - можно разыменовывать и пользоваться новым объектом. Но нет - объект создался не совсем там, где мы ожидали - он очутился “в новом слое” по тому же адресу, а t1 смотрит мимо него. И стрелка от t1 сама себя к новому объекту не подвинет! В итоге компилятор может считать, что t1 указывает невалидный объект. Не правда ли контринтуитивно?
Выводы главы
Начиная с C++17 связки “адрес + тип” недостаточно, чтобы полностью идентифицировать указатель
У указателей есть provenance - память компилятора о том, от какого объекта произошел указатель
Если вы пытаетесь разыменовать указатель, за которым нет provenance с живым объектом, вы получаете UB
Два указателя с одинаковым адресом теоретически могут указывать на разные объекты. Даже если указатели имеют один тип
Transparently replaceable объекты и std::launder
Вообще, говоря “стрелка от t1 сама себя к новому объекту не подвинет”, я вам откровенно врал. Потому что в общем случае, конечно не подвинет, но в тот же стандарт C++17 завезли понятие transparently replaceable объектов:
Определение transparently replaceable
[basic.life] p9
An object o1 is transparently replaceable by an object o2 if either
o1 and o2 are complete objects for which:
o1 is not const,
the storage that o2 occupies exactly overlays the storage that o1 occupied, and
o1 and o2 are of the same type (ignoring the top-level cv-qualifiers), or
o1 and o2 are corresponding direct subobjects for which:
the complete object of o1 is not const or
o1 is a mutable member subobject or a subobject thereof.
Применение этой концепции:
Действие transparently replaceable
[basic.life] p10
After the lifetime of an object has ended and before the storage which the object occupied is reused or released, if a new object is created at the storage location which the original object occupied and the original object was transparently replaceable by the new object, a pointer that pointed to the original object, a reference that referred to the original object, or the name of the original object will automatically refer to the new object and, once the lifetime of the new object has started, can be used to manipulate the new object.
Note: If these conditions are not met, a pointer to the new object can be obtained from a pointer that represents the address of its storage by calling
std::launder.
То есть, если два объекта transparently replaceable, то в случае пересоздания по месту стрелочка от одного к другому объекту перемещается автоматически. И заметьте, в моем примере выше:
T* t1 = new T; t1->~T(); T* t2 = new (t1) T;
t1 и t2 -таки являются указателями на transparently replaceable объекты, поскольку имеют один тип, занимают ровно ту же память и объект за t1 неконстантен. Т.е. совершенный мною подлог очевиден: сначала я дал вам базовое правило, не упомянув про жирное исключение, а теперь задним числом ввожу это исключение. Все ради последовательного усвоения. Верная картинка должна быть такой:

А еще заметьте ноту в конце параграфа Действие transparently replaceable - если так вышло, что ваши объекты не transparently replaceable, то положение можно исправить вызовом std::launder.
Знаете, я не мог понять std::launder очень долго. Но с концепцией слоев памяти все становится на свои места: std::launder просто корректирует стрелочку. Как он это делает:
Определение std::launder
[ptr.launder] p2-p3
Preconditions: p represents the address A of a byte in memory. An object X whose type is similar to T is located at the address A, and is either within its lifetime or is an array element subobject whose containing array object is within its lifetime. All bytes of storage that would be reachable through the result are reachable through p. Returns: A value of type T* that points to X.
Если проще: если по адресу указателя где-нибудь существует живой объект такого же типа, std::launder выдаст вам новый указатель с передвинутой на этот объект стрелочкой. В терминах нашей модели можно думать об этом как о “исправлении provenance”: вам выдается указатель, который связан с находящимся по тому же адресу живым объектом. Если подходящего объекта по этому адресу нет, применение std::launder не разрешено. Если объект есть и исходный pointer уже указывает на него, результат будет указывать на тот же объект.
Как вы можете понять, в обычном языковом выражении функция ничего не делает - это скорее подсказка компилятору, нежели обычная функция. Самое лаконичное описание смысла функции заключается в человекочитаемом названии параграфа [ptr.launder]: “Pointer optimization barrier”. И действительно - починка pointer provenance у “указателя с душком” просто лишает компилятор возможности спекулировать на его безобъектности.
Чаще всего современный std::launder водится там, где вы хотите получить указатель на валидный объект, изначально имея на руках указатель другого типа, который размещен по тому же адресу - специфичный кейс, о нем мы поговорим в другой главе. Если же мы говорим о пересоздании объектов одного типа по одному адресу, то в 90% случаев объекты будут transparently replaceable из-за одинаковости типов, и в вызове std::launder никогда нет надобности. Однако же, можно извернуться и подобрать синтетический пример, когда в памяти размещается объект того же типа, но условия transparently replaceable не соблюдаются:
struct Wrapper { T t; }; static_assert(sizeof(Wrapper) == sizeof(T)); static_assert(alignof(Wrapper) <= alignof(T)); ... T* t1 = new T;
За t1 находится живой объект T

t1->~T();
Объекта не стало, t1 - сирота

Wrapper* w = new (t1) Wrapper;
Создав объект Wrapper по адресу t1, мы фактически по этому же адресу положили и новый объект типа T, т.к. *w и w->t имеют нулевой offset, и следовательно оба лежат по адресу t1. Но T* t1 все равно все еще сирота, поскольку старый объект под t1 и новый w->t не transparently replaceable - они оба не complete objects и не имеют связи объект-подобъект, т.е. никак не укладываются в Определение transparently replaceable

T* t2 = std::launder(t1);
К t1 мы нормально обращаться не можем, поэтому создаем через std::launder новый указатель t2 с provenance, отсылающим к объекту w->t

Выводы главы
Transparently replaceable объекты могут “передать” свой provenance, если один такой объект пересоздать на месте другого. Указатель на старый объект автоматически станет указывать на новый объект
Если в теле старого объекта вы создаете новый объект того же типа, вы почти всегда получите автоматический перевод старых указателей на новый объект. Помешать этому могут только константность старого объекта или размещение нового объекта не строго в той же памяти, в которой был старый объект
Не transparently replaceable объекты одного типа можно “починить” вызовом
std::launder
Char и type-accessibility
Есть два типа:
struct T { int x; }; struct U { int x; };
Теперь, зная про pointer provenance, мы понимаем, почему объект типа T нельзя использовать, интерпретировав его как тип U - если у вас был объект T, и никогда не было объекта U, никакое знание о том, что у них одинаковый layout и т.д. не спасет вас от UB, потому что компилятору виднее.
T t; U u = *reinterpret_cast<U*>(&t); // UB
Однако же, обложив себя такими UB, стандарт лишил себя возможности иметь побайтовый доступ к объектам - ведь инспектировать объект у вас получится, лишь приведя его к однобайтовому типу, через который вы сможете просмотреть внутрянку объекта:
T t; char* bytes = reintepret_cast<char*>(&t); std::cout << bytes[1];
Но ведь объектов char на самом деле нет там, где мы пытаемся через них смотреть!
Поэтому стандарт наделил особым статусом типы char, unsigned char и std::byte и ввел понятие type-accessible types:
Определение type-accessible
[basic.lval] p11
An object of dynamic type Tobj is type-accessible through a glvalue of type Tref if Tref is similar to:
Tobj,
a type that is the signed or unsigned type corresponding to Tobj, or
a char, unsigned char, or std::byte type.
If a program attempts to access the stored value of an object through a glvalue through which it is not type-accessible, the behavior is undefined
Зачем оно может быть нужно:
Точечная инспекция. Посмотреть один байт, проверить порядок байт, вывести дамп
Легализация
memcpy. Точнее - объяснение, почемуmemcpyне нарушает strict aliasing
Вот например, поверка endianness машины, на которой выполняется код:
bool little_endian() { int i = 1; return *reinterpret_cast<unsigned char*>(&i) == 1; }
Стандарт даже разрешает вам переписывать байты объекта через char*. Стандарт лишь оговаривает, что если изменение object representation приводит к битовому состоянию, которое не является допустимым для данного типа, попытка прочитать значение такого объекта может привести к UB:
Правило битовой консистентности
[conv.lval] p3.3
Otherwise, if the bits in the value representation of the object to which the glvalue refers are not valid for the object’s type, the behavior is undefined.
Выводы главы
Кастите любой указатель на объект в
char*,unsigned char*илиstd::byte*и инспектируйте байтовую структуру объекта в свое удовольствие
Provides Storage
Я уже приводил вам примеры вида:
double d = 15.0; new (&d) long long(4);
где создание long long в памяти объекта double прерывает жизнь объекта. Такое уничтожение объекта штатное, и работает в общем случае:
Условие конца жизни объекта
[basic.life] p2
… The lifetime of an object o of type T ends when:
if T is a non-class type, the object is destroyed, or
if T is a class type, the destructor call starts, or
the storage which the object occupies is released, or is reused by an object that is not nested within _o …
Третий пункт как раз про переиспользование памяти, занятой объектом.
Но есть случаи, когда это поведение мешает: например, когда вы создаете объект в теле байтового массива, чтобы реализовать SSO или type erasure. Этот случай стандарт тоже оговорил отдельно и сделал еще одно исключение из правил через определение provides storage:
Определение provides storage
[intro.object] p3 If a complete object is created in storage associated with another object e of type “array of N unsigned char” or of type “array of N std::byte”, that array provides storage for the created object if
the lifetime of e has begun and not ended, and
the storage for the new object fits entirely within e, and
there is no array object that satisfies these constraints nested within e.
Объекты типа unsigned char[] или std::byte[] не умирают, если в их памяти создавать объекты, поскольку стандарт явно указал их как типы-хранилища. Помните пример?:
alignas(Point) std::byte[sizeof(Point)] data; Point* p = new (data) Point; p->~Point();
Объект std::byte[] не умирает, хотя в его памяти создали объект Point.
Заметьте, что объекты типа char[] не наделили такими же полномочиями, хотя для type-accessibility char вполне себе подходил.
alignas(T) unsigned char buf1[sizeof(T)]; // предоставляет хранилище alignas(T) std::byte buf2[sizeof(T)]; // предоставляет хранилище alignas(T) char buf3[sizeof(T)]; // НЕ предоставляет
Стандарт добился, чтобы объект-хранилище не умирал при размещении в нем других объектов. Но зачем? Казалось бы - пускай умирает, и мы продолжим размещать в этом мертвом буфере свои переменные. Ведь если мы выделяем память через malloc на куче, то все так и происходит - мы просто работаем с куском памяти, в котором ничего нет, и это нормально.
alignas(T) std::byte[sizeof(T)] buf; T* t = new (buf) T;
vs
std::byte* buf = static_cast<std::byte*>::operator new(sizeof(T), std::align_val_t(alignof(T)))); T* t = new (buf) T;
Функционально это одно и то же. В обоих случаях вы хотите буфер, в котором будете размещать объект. Вот только, когда вы реализуете SBO (small buffer optimization), вы хотите размещаться на стеке - в этом и есть суть оптимизации. И вы выбираете std::byte[].
Но с std::byte[] есть важный момент - это обычная переменная, просто вы решили использовать ее не совсем типовым для языка способом. На деле я мог бы использовать ее по целевому назначению, как массив: buf[3] = 3;. Но я могу так делать, если за buf стоит живой объект. В случае с буфером, динамически выделенным в виде сырой памяти, я в принципе не могу так делать (за исключением implicit-lifetime случаев, но это другое).
Давайте продолжим код:
alignas(T) std::byte[sizeof(T)] buf; T* t = new (buf) T; // 1 t->~T(); // 2 t = new (buf) T; // 3
Без исключения для providing storage мы имели бы такую картину:
1
bufжив2
bufмертв3
bufмертв
Но на деле buf всегда живой. А это означает, что мы всегда можем вернуться к нему и использовать его как массив, что выглядит более корректно, если держать в голове, что buf - не только буфер байт, но и обычная переменная.
Что любопытно, формально SBO можно написать и с char[] - да хоть uint8_t[] или bool[] - и даже не нарваться ни на один UB, но как только вы полезете делать копию буфера через memcpy или читать его как байты, вы немедленно получите UB, потому что объект массива умирает при первом же размещении в нем чего-либо и больше никогда не воскрешается.
А еще при работе с provides storage буфером, внезапно, снова всплывает std::launder, т.к. иногда у нас возникает необходимость соскочить с объекта std::byte[] на целевой объект T:
template<typename T> class Buffer { public: void reset() { new (buf) T; // 1 } T& get() { return *std::launder( // 3 reinterpret_cast<T*>(buf) // 2 ); } private: alignas(T) std::byte[sizeof(T)] buf; }; void f() { Buffer<T> buf; buf.reset(); T& t = buf.get(); }
1 Вы размещаете в теле
bufобъектT. Вообще, placement new возвращает вам указатель с хорошим provenance, привязанным к объектуT, но интерфейсу вашего класса некуда деть или применить этот возвращаемый указатель, поэтому мы им не пользуемся2 Каст дает вам
T*с плохим provenance - он все еще указывает наstd::byte[]3
std::launderвыдает поправленный указатель - теперь он указывает на объектT
На диаграмме t₁ - указатель после каста; t₂ - после std::launder:

Хочу отметить, что в стандарт в данный момент делают предложение P3006R1, которое призвано убрать необходимость в std::launder для provides storage буферов. Если это произойдет, std::launder станет функцией с исчезающе малой необходимостью, т.к. в большинстве случаев все будет работать и без него.
Выводы главы
Для обычных типов размещение в их теле других объектов означает их немедленное разрушение (и деструктор не вызовется!)
unsigned char[]иstd::byte[]- provides storage типы. При размещении в теле переменных такого типа других объектов, provides storage объекты не разрушаются, а живут с ними параллельноИспользуйте эти типы для SBO-оптимизации
char[]- не provides storage типВыполнение
reinterpret_cast<T*>(buf)- недостаточное действие, чтобы получить корректный указатель. Его provenance все еще будет ссылаться на переменную самого буфера. Для коррекции указателя вам понадобитсяstd::launder
Pointer-interconvertibility + reinterpret_cast
Я всегда полагал, что reinterpret_cast - каст последней надежды, который может скастить все что угодно во все что угодно, если другие касты с этим не справились. Это, конечно, было достаточно наивно.
Со временем я узнал, что полномочия reinterpret_cast совершенно не всемогущие, скорее наоборот - есть узкий набор сценариев, где вы можете его использовать, не получив впоследствии UB.
В этой главе я хочу акцентировать внимание на касте указателей. Стандарт отдельно оговаривает, чем является reinterpret_cast указателей:
reinterpret_cast для указателей
[expr.reinterpret.cast] p7
… When a prvalue v of object pointer type is converted to the object pointer type “pointer to cv T”, the result is
static_cast<cv T*>(static_cast<cv void*>(v)).
То есть все банально сводится к static_cast в указатель другого типа через void*. А про такой каст стандарт пишет следующее:
static_cast для void*
[expr.static.cast] p12
… If the original pointer value represents the address A of a byte in memory and A does not satisfy the alignment requirement of T, then the resulting pointer value is unspecified. Otherwise, if the original pointer value points to an object a, and there is an object b of type similar to T that is pointer-interconvertible with a, the result is a pointer to b. Otherwise, the pointer value is unchanged by the conversion.
Здесь возникает ключевое понятие: pointer-interconvertible объекты. Заметьте, как стандарт ловко соскочил с понятия “указатель” на понятие “объект”, чтобы описать понятие pointer-interconvertibility в терминах объектности:
Определение pointer-interconvertible
[basic.compound] p7
Two objects a and b are pointer-interconvertible if
they are the same object, or
one is a union object and the other is a non-static data member of that object, or
one is a standard-layout class object and the other is the first non-static data member of that object or any base class subobject of that object, or
there exists an object c such that a and c are pointer-interconvertible, and c and b are pointer-interconvertible.
If two objects are pointer-interconvertible, then they have the same address, and it is possible to obtain a pointer to one from a pointer to the other via a reinterpret_cast. Note: An array object and its first element are not pointer-interconvertible, even though they have the same address. — end note
В итоге мы получаем довольно узкий круг сценариев, где указатель на один объект может быть сконвертирован в указатель на другой объект через reinterpret_cast. Именно на другой объект! static_cast для void* не зря говорит в конце: “Otherwise, the pointer value is unchanged by the conversion”. Т.е. если объекты оказались не pointer-interconvertible, то возвращенный вам указатель будет иметь provenance старого, исходного объекта. И вы скорее всего не сможете пользоваться таким указателем, т.к. тип указателя и тип объекта, на который он указывает, не совпадают.
Наглядные примеры pointer-interconvertible объектов:
union T { Point p; int i; }; T t; int& i = *reinterpret_cast<int*>(&t); // 1 i = 15; *reinterpret_cast<Point*>(&t) = {3, 2}; // 2
1 Объекты
tиt.ipointer-interconvertible2 Объекты
tиt.ppointer-interconvertible
struct Point { int x; int y; }; Point p; int& x = *reinterpret_cast<int*>(&p); // 1 x = 15;
1 Объекты
pиp.xpointer-interconvertible, посколькуx- первый член standard-layout структуры, дляyэто не работает
struct Entity { }; struct Player : Entity { int health; }; Player* p = new Player{200}; Entity* e = reinterpret_cast<Entity*>(p); // 1 int* h = reinterpret_cast<int*>(p); // 2 int* h2 = reinterpret_cast<int*>(e); // 3
1 Объекты
PlayerиEntitypointer-interconvertible, т.к.Entity- base subclass дляPlayer2 Объекты
Playerиintpointer-interconvertible, т.к.health- первый член standard-layout структуры3 Объекты
Entityиintpointer-interconvertible транзитивно
Помимо pointer-interconvertible объектов не забываем про type-accessibe объекты: любой указатель на объект можно безопасно скастить через reinterpret_cast в char*, unsigned char*, std::byte* и инспектировать голые байты.
За рамками этих кейсов на территории корректного reinterpret_cast остается совсем уж экзотика: round-trip указателя. Это когда мы кастим указатель в какую-то дичь, а потом кастим обратно в свой тип. Стандарт дает нам гарантию, что такой указатель возвращается к нам из кастового путешествия целым и невредимым:
Pointer round-trip
[expr.reinterpret.cast], p7, Note 7
Converting a prvalue of type “pointer to T1” to the type “pointer to T2” (where T1 and T2 are object types and where the alignment requirements of T2 are no stricter than those of T1) and back to its original type yields the original pointer value.
Point* p1 = new Point(12, -2); Chair* p2 = reinterpret_cast<Chair*>(p1); Point* p3 = reinterpret_cast<Point*>(p2);
p3 будет полностью эквивалентен p1, и его можно использовать как обычно.
Еще одна разновидность round-trip сценария: конвертация значения указателя в число и обратно:
Pointer-intergral round-trip
[expr.reinterpret.cast] p5
A value of integral type or enumeration type can be explicitly converted to a pointer. If the value is one that can be produced by converting one or more pointer values to an integral type, the result is an unspecified choice among all such values that would result in the program having defined behavior. If no such value exists, the behavior is undefined.
T* p = new T; uintptr_t addr = reinterpret_cast<uintptr_t>(p); T* q = reinterpret_cast<T*>(addr);
p эквивалентен q. Правда, лишь тогда, когда у вас есть один такой указатель с конкретным адресом addr. В ином случае обратная конвертация в указатель выдаст вам любой из указателей, существующих по такому адресу: unspecified choice, как это назвали в стандарте.
Чего не может reinterpret_cast
Определение pointer-interconvertible в конце имеет ноту, которая явно говорит, что нельзя делать reinterpret_cast от массива к его первому элементу - это не pointer-interconvertible случай. Но это и не нужно! Ведь семантика преобразования указателей и массивов позволяет обойтись вообще без каста:
int a[10]; int* pGood = a; // 1 int* pBad = reinterpret_cast<int*>(&a); // 2
1 Так можно
2 Так вы получаете
int*c provenance на объектint[]. Разыменование - UB
Еще один случай беспомощности reinterpret_cast - каст наследников в потомков и обратно. reinterpret_cast в общем случае не справляется с этой задачей:
struct Base { int i; }; struct Derived : Base { float f; }; Derived* d = new Derived; Base* b1 = static_cast<Base*>(d); // 1 Derived* d1 = static_cast<Derived*>(b1); // 2 Base* b2 = reinterpret_cast<Base*>(d); // 3 Derived* d2 = reinterpret_cast<Derived*>(b1); // 4
1 OK
2 Потенциально OK
3 Вы получаете плохой указатель - его provenance сломан (указывает на объект
Base, имеет типDerived*) без возможности починить его черезstd::launder(типы разные)4 Схоже с 3 но в обратную сторону
Однако же, вы можете вспомнить, что в Определении pointer-interconvertible третий пункт описывал случай с наследованием. Но это работает только для standard-layout классов, а чтобы иерархия классов оставалась standard-layout, нужно соблюсти специфические условия - в частности только у одного класса в иерархии могут быть нестатические члены. А это довольно узкий кейс, на который закладываться в разработке не стоит.
В любом случае стоит сказать, что reinterpret_cast - просто не тот инструмент, которым мы “ходим” по иерархии. Этими вещами занимаются static_cast и dynamic_cast. В частности они понимают множественное наследование и даже могут корректировать результирующий адрес. reinterpret_cast так не умеет в принципе.
Выводы главы
reinterpret_castуказателя без UB - это каст вchar*,unsigned char*,std::byte*; каст междуunionи его нестатическими членами; между standard-layout классом и его первым нестатическим членомПутешествие указателя
T*через цепочку кастовreinterpret_castбезопасно, если мы снова приходим кT*Путешествие указателя
T*из указателя в число и обратно черезreinterpret_castбезопасно, если указатель с таким адресом был одинreinterpret_castне может отнять хлеб уstatic_cast, особенно если дело касается иерархий классов
Тонкости деструкторов
Деструктор - функция, вызываемая в процессе уничтожения объекта. Несмотря на его важную роль в жизни объекта, к моему удивлению в текущей редакции стандарта (working draft C++26) взаимосвязь деструктора и времени жизни объекта описана уж совсем не идеально.
При изучении стандарта я увидел немало логических пробелов и недосказанностей, оставляющих пространство для неоднозначных трактовок. Более того, сравнение с предыдущими редакциями показывает, что формулировки, описывающие эту взаимосвязь, активно менялись и продолжают уточняться. Я бы сказал, что это самая непроработанная часть стандарта, с которой мне довелось работать в процессе написания статьи. Большей частью она находится в разделе [basic.life], где и происходят основные передовые изменения относительно жизни объектов.
Поэтому на дату написания статьи - 26 сентября 2026 года - эту часть стандарта стоит разделить на две условные категории. Есть устоявшиеся правила, на которые можно уверенно опираться, и есть более сырая часть, трактовки которой часто менялись и будут меняться в недалеком будущем.
Для последней я буду формулировать скорее практические предостережения - чего делать не стоит, чтобы остаться в безопасной зоне даже там, где сам стандарт пока не дает достаточно надежных и однозначных правил.
Незыблемые правила
Первое неизменное достаточно очевидно: вызов деструктора по мертвому объекту - это UB:
Запрет повторного уничтожения
[class.dtor] p18
Once a destructor is invoked for an object, the object’s lifetime ends; the behavior is undefined if the destructor is invoked for an object whose lifetime has ended.
[Example 3: If the destructor for an object with automatic storage duration is explicitly invoked, and the block is subsequently left in a manner that would ordinarily invoke implicit destruction of the object, the behavior is undefined. — end example]
Example 3 в тексте стандарта показывает один из вариантов получения такой UB-ситуации. И этот вариант обеспечивается вторым незыблемым правилом - правилом о неявных вызовах деструктора:
Неявные вызовы деструктора
[class.dtor] p14
A destructor is invoked implicitly
for a constructed object with static storage duration at program termination,
for a constructed object with thread storage duration at thread exit,
for a constructed object with automatic storage duration when the block in which an object is created exits,
for a constructed temporary object when its lifetime ends.
Самый известный из таких вызовов - конечно же, неявный вызов деструктора по выходу переменной из скоупа; automatic storage сценарий:
{ std::vector<int> v(100); } // 1
1 Неявный вызов деструктора
~vector<int>()
Такие неявные вызовы деструктора заранее спланированы компилятором, и их невозможно отменить: их вызов произойдет не смотря ни на что.
В принципе, уже с этими двумя правилами - связкой Неявные вызовы деструктора + Запрет повторного уничтожения - можно отлично жить, поскольку из них можно вывести ситуации, где ничем хорошим дело закончиться не может или может, но это не точно (это как раз реверанс в сторону сырой части стандарта, где трактовки могут нас вывести куда угодно):
-
Явный повторный вызов деструктора ведет вас прямо к UB без шансов
Point p; p.~Point(); // 1 p.~Point(); // 21 Уничтожение объекта
2 Попытка уничтожить мертвый объект - UB
-
К тем же результатам приведет неявный повторный вызов деструктора
{ Point p; p.~Point(); // 1 } // 21 Уничтожение объекта
2 Неявный запланированный компилятором вызов деструктора - такой же UB
Также стоит отметить, что в те же моменты, когда планируется неявный вызов деструктора, другая часть стандарта описывает освобождение памяти, занятой automatic, static или thread_local переменной. Эти правила распределены по всей главе [basic.stc.auto], однако соответствующие моменты времени совпадают с моментами, в которые планируется неявный вызов деструктора. Кроме того, стандарт явно оговаривает, что для объекта с деструктором освобождение storage происходит после выполнения деструктора. Таким образом, соблюдается ожидаемая последовательность: сначала завершается время жизни объекта, затем освобождается занимаемая им память.
В качестве бонуса покажу что-то уже более пограничное, но все еще с некоторым зерном стабильности. Мы уже упоминали параграф Условие конца жизни объекта в главе про provides storage типы. Я хочу привести его еще раз, но со смещенными акцентами:
Условие конца жизни объекта
[basic.life] p2
… The lifetime of an object o of type T ends when:
if T is a non-class type, the object is destroyed, or
if T is a class type, the destructor call starts, or
the storage which the object occupies is released, or is reused by an object that is not nested within _o …
Сразу скажу, что сам параграф не является лучшим образчиком стабильности и имеет тенденцию в переписыванию, однако выделенное мной практически железобетонно, а невыделенное создает важный “фон”. Смотрите, что можно выжать из этого параграфа:
Вывод первый: вызов деструктора провоцирует смерть объекта. Это и есть официальная связка деструктора со временем жизни объекта.
Вывод второй: не обязательно вызов деструктора провоцирует смерть объекта - это лишь один из сценариев. Если перефразировать иначе: деструктор при смерти вызывается не всегда, даже если он существует. И это уже интересно, поскольку деструктор содержит часть логики, на которую полагается программа. Но как видите, если просто бахнуть free по памяти объекта или, например, пересоздать в теле объекта другой объект, мы получаем уничтожение объекта без вызова деструктора. Я не стану смело утверждать, UB это или нет, поскольку, с одной стороны, такой сценарий делает невалидной логику программы во всех случаях, когда деструктор содержит реальный код; с другой стороны на текущий момент стандарт не расценивает это поведение как UB как таковой, но это запросто может измениться.
Вывод третий: “if T is a class type” жирно напоминает нам, что так-то деструкторы есть только у классовых типов. А что же тогда с неклассовыми типами? И тут есть кое-что интересное…
Псевдо-деструктор
Деструктор - понятие, существующее только у классов. Стандарт об этом говорит не только в параграфе Условие конца жизни объекта. Вот более подходящий параграф:
Типы с (псевдо-)деструкторами
[expr.prim.id.dtor] p1
An id-expression that denotes the destructor of a type T names the destructor of T if T is a class type, otherwise the id-expression is said to name a pseudo-destructor.
Интересно выходит: для классов - деструкторы, для неклассов - псевдо-деструкторы. Следующим параграфом идет важное уточнение:
Скаляры и псевдо-деструкторы
[expr.prim.id.dtor] p2
If the id-expression names a pseudo-destructor, T shall be a scalar type…
Выходит, псевдо-деструкторы существуют только у скалярных типов.
Эта концепция - что деструкторы присущи классовым типам, псевдо-деструкторы - скалярным типам, оставляет некоторый узкий круг типов вообще без единого намека на какой-либо (псевдо-)деструктор:
Ссылки
Функциональные типы (
int())voidИ, внезапно, массивы. У массивов (псевдо-)деструктор может быть у его элементов, если, конечно, они не относятся к типам из текущего списка
Те, кто имеют псевдо-деструктор, могут его явно вызывать:
Использование псевдо-деструкторов
[class.dtor] p19, Note 10
The notation for explicit call of a destructor can be used for any scalar type name. Allowing this makes it possible to write code without having to know if a destructor exists for a given type. For example:
typedef int I; I* p; p->I::~I();
typedef в данном примере необходим, поскольку ~int - синтаксическая ошибка (!).
Обратите внимание на слова “without having to know if a destructor exists for a given type”. Здесь имеется в виду шаблонный код. С наличием псевдо-деструкторов код будет работать без синтаксических ошибок даже если вызывается ~T(), где T = int. И это удобно.
Начиная с C++20 псевдо-деструктор наделили семантическим смыслом: теперь он завершает время жизни скалярного объекта, так же как это делает вызов нормального деструктора для классовых объектов. Еще в C++17 псевдо-деструктор сам по себе был буквально no-op, и не делал ничего - только позволял существовать такой конструкции, не вызывая возмущений компилятора - просто заглушка для компилируемости шаблонного кода:
// C++17 using I = int; int i = 14; i.I::~I(); std::cout << i; // OK
В C++17 жил параграф [expr.pseudo], который это явно прописывал:
Псевдо-деструкторы в C++17
C++17 [expr.pseudo] p1
The use of a pseudo-destructor-name after a dot . or arrow -> operator represents the destructor for the non-class type denoted by type-name or decltype-specifier. The result shall only be used as the operand for the function call operator (), and the result of such a call has type void. The only effect is the evaluation of the postfix-expression before the dot or arrow.
В случае с i.I::~I(); post-expression - это всего лишь i, т.е. сам псевдо-деструктор в C++17 не делает ничего, совсем.
В C++20 же в силу вступают правила времени жизни объекта: потрогал (псевдо-)деструктор - уничтожил объект. Параграф [expr.pseudo] ныне не существует в стандарте, зато современный [expr.call] оговаривает новое семантическое поведение для псевдо-деструктора:
Псевдо-деструкторы в C++20
С++20 [expr.call] p4
… If the postfix-expression names a pseudo-destructor …, the function call destroys the object of scalar type denoted by the object expression of the class member access.
// C++20 using I = int; int i = 14; i.I::~I(); // объект уничтожен std::cout << i; // UB
Трогать уничтоженное - UB.
Важно: стандарт очень строго бдит формулировки. Если в стандарте написано “destructor”, всегда будет иметься в виду настоящий классовый деструктор и не иначе. Псевдо-деструкторы всегда называются псевдо-деструкторами, стандарт не обобщает эти понятия.
В частности такие параграфы, как Неявные вызовы деструктора, к псевдо-деструкторам не относятся. То есть псевдо-деструктор вы можете вызвать только руками - компилятор не генерирует их за вас тайком. А уничтожение скалярного объекта при выходе из скоупа обуславливается исключительно освобождением памяти под объект.
Trivial destructor
Есть категория типов, которая называется trivially destructible. Это типы с тривиальным деструктором:
Определение тривиального деструктора
[class.dtor] p8
A destructor for a class X is trivial if it is not user-provided and if
the destructor is not virtual,
all of the direct base classes of X have trivial destructors, and
either X is a union or for all of the non-variant non-static data members of X that are of class type (or array thereof), each such class has a trivial destructor. Otherwise, the destructor is non-trivial.
Ключевое/основное тут - not user provided. Проще говоря - если вы не объявите деструктор явно, то он будет тривиальным. Сюда же, внезапно, примкнули псевдо-деструкторы - они так же тривиальные, поэтому все скалярные типы trivially destructible. Trivially destructible типы можно определить функцией std::is_trivially_destructible.
Основная особенность тривиального деструктора заключается в том, что такой деструктор не содержит в себе логики и сайд-эффектов. Компилятор на месте его вызова будет генерировать no-op. И на этом теоретически можно спекулировать. Например, намеренно уничтожать объект без вызова деструктора, “ибо там все равно ничего нет”; или считать, что неявный вызов деструктора по выходу переменной на скоупа вам теперь не страшен, а значит в рамках скоупа можно творить дичь. Я бы, конечно, так спекулировать не стал - потому что стандарт здесь сидит в серой зоне, и завтра спекуляция может попасть под UB.
Как не делать
Я не стану приводить противоречивые или недостаточно устоявшиеся параграфы стандарта, поскольку с ними статья устареет за считанные годы, если не месяцы. Расскажу лишь, как себя обезопасить и писать код, который с малой вероятностью сможет переквалифицироваться из валидного в UB-несущий спустя несколько редакций стандарта.
Не полагайтесь на trivial destructor
Не стоит спекулировать на том, что “вызов тривиального деструктора” эквивалентно “нет вызова деструктора”, хотя, конечно, соблазн так считать велик. Сейчас стандарт не делает различий между генерацией компилятором обычного деструктора и тривиального деструктора - вызов происходит в любом случае.
Да, это может быть no-op в конечном ассемблере, но не стоит забывать, что вызов деструктора - это еще и конец времени жизни объекта и сопряженные с этим последствия.
Т.е. тривиальный деструктор в частности не освобождается от UB после попытки двойного уничтожения объекта.
Единственные типы, с которыми здесь можно поступать более-мене фривольно - скалярные типы. У них нет деструктора - только псевдо-деструктор. Помним, что псевдо-деструктор не вызывается неявно. Соответственно вы не можете попасть в ловушку неявного двойного уничтожения объекта со скалярными типами. Ну и типы без деструкторов и псевдо-деструкторов вовсе индульгированы от этой проблемы полностью.
Не уничтожайте руками объект на стеке
На самом деле это относится и к static и thread_local переменным в том числе. Просто не вызывайте у таких переменных деструктор руками, и вы обезопасите себя от уймы проблем.
Зачем в реальном коде вам может понадобиться явно вызывать деструктор для стековой переменной? Например, чтобы реализовать SBO. Но верный, проторенный путь для реализации SBO уже существует: пользуйтесь provides storage массивами - они уже имеют прощение стандарта и дают вам хорошую защиту от UB:
Provides storage буферы не уничтожаются при размещении в их теле других объектов, а живут до конца скоупа
Provides storage буферы будучи массивами не имеют (псевдо)-деструктора, поэтому по выходу из скоупа для них просто ничего не вызывается
Т.е. только со стековыми std::byte[] и unsigned char[] вы в полной безопасности.
А с обычными типами ручной вызов деструктора безопасен только если вы выделили тип на куче - для такого размещения неявный вызов деструктора не запланирован, и вы можете абсолютно контролировать ситуацию - вызывать деструктор руками, делать placement new, вызывать деаллокацию памяти.
Деструктор и время жизни объекта
Хочу обратить внимание на один важный и немного странный момент. Стандарт ассоциирует вызов деструктора и конец времени жизни объекта и пытается сделать так, чтобы они шли рука об руку, были практически тождественны. Однако, здесь есть фундаментальная проблема, из-за которой их абсолютное временнóе совпадение попросту недостижимо в общем случае.
Дело в том, что время жизни объекта - рантаймовое понятие, о чем стандарт заявляет явно:
Райнтаймовость времени жизни
[basic.life] p2
The lifetime of an object or reference is a runtime property of the object or reference. …
Вызов же деструктора - это вызов функции, генерируемый компилятором на стадии компиляции программы. То есть это понятия из разных миров, которые стандарт всеми силами пытается сблизить и сопоставить по времени.
И получить расхождение совсем несложно - по сути мы с вами уже видели примеры кода, где это вовсю происходит:
Объект умирает, а деструктор даже не отрабатывает
// birth void* p = ::operator new(sizeof(std::vector<int>)); auto* v = new (p) std::vector<int>(100); // death ::operator delete(p); // 1
1 Параграф Условие конца жизни объекта показывал нам, что можно убить объект просто освобождением памяти - деструктор при этом не вызовется. Произойдет утечка
sizeof(int) * 100байт, отсутствие UB в текущей редакции стандарта и корректно завершенный жизненный цикл вектора. Такие дела.
Деструктор вызывается, а объект уже мертв
Это мы уже видели:
{ std::string s("qwertyuiopasdfg"); s.~string(); // 1 ... } // 2
1 Первое уничтожение объекта
2 Двойное уничтожение объекта
Деструктор вызывается, а в этой памяти уже располагается другой объект
{ T t; new (&t) T; // 1 } // 2
1 Переиспользование памяти, выделенной под
t. Деструктор для старого объекта не отработал2 Неявный вызов деструктора у объекта - живого, но не первоначального
Надо сказать, это очень скользкая тема - легально ли так делать, и будет ли UB в пункте 2. И дело здесь в первую очередь в немногословности стандарта на этот счет. Хочу остановиться на этой истории поподробнее, так как она показательна и показывает текущее вопиющее состояние дел.
Давайте перечитаем наш основополагающий параграф про неявные вызовы деструктора:
Неявные вызовы деструктора
[class.dtor] p14
A destructor is invoked implicitly
for a constructed object with static storage duration at program termination,
for a constructed object with thread storage duration at thread exit,
for a constructed object with automatic storage duration when the block in which an object is created exits,
for a constructed temporary object when its lifetime ends.
“For a constructed object” - все, что мы имеем. Кто-то трактует это в пользу оригинального сконструированного объекта. То есть мы сконструировали объект когда-то в начале скоупа, для него запланировали вызов деструктора. В таком случае, когда придет время деструктора, единственно корректный вариант - вызвать его для оригинального живого объекта.
Иная трактовка заключается в том, что “constructed object” - это любой живой сконструированный объект. Текущие достаточно сырые на мой взгляд параграфы из [basic.life] подтверждают эту трактовку, но точно так же поддающимися обширным трактовкам словами.
Из самого любопытного, на что я наткнулся в процессе изысканий - пост на Stack Overflow. Да-да, меня занесло туда в 2026 году, поскольку я хотел перепроверить убедительно-сомнительные теории ИИ-чатов о происходящем и почитать живых людей - что считают они на этот счет. Один из отвечающих написал, что он в свое время связывался с Core-командой комитета по стандартизации с этим вопросом:
[class.dtor]p12 isn’t very accurate. I asked Core about it and Mike Miller (a very senior member) said:
I wouldn’t say that it’s a contradiction [[class.dtor]p12 vs [basic.life]p9], but clarification is certainly needed. The destructor description was written slightly naively, without taking into consideration that the original object occupying a bit of automatic storage might have been replaced by a different object occupying that same bit of automatic storage, but the intent was that if a constructor was invoked on that bit of automatic storage to create an object therein - i.e., if control flowed through that declaration - then the destructor will be invoked for the object presumed to occupy that bit of automatic storage when the block is exited - even it it’s not the “same” object that was created by the constructor invocation.
Кстати, можно увидеть, что с тех времен (2018 год), номера параграфов слегка поплыли - именно по этой причине я не хочу ими злоупотреблять в статье.
Как видим, теория, что “constructed object” - это любой живой сконструированный объект, кажется верна. Но я все равно призываю вас: не уничтожайте руками объект на стеке, не создавайте такие ситуации, НЕ ПРОВОЦИРУЙТЕ - эта тема мутная, нестабильная и требует персональных объяснений от членов комитета.
К тому же все равно все начинает рассыпаться, если вы сделаете шаг в сторону. Смотрите внимательно:
{ std::vector<double> v(10); new (&v) std::vector<int>; // 1 ... } // 2
1 Переиспользование памяти, выделенной под
v2 UB
Угадаете, откуда UB? Мы сменили тип. С std::vector<double> на std::vector<int>. И все рассыпалось - ведь неявный вызов деструктора запланирован на стадии компиляции для типа std::vector<double>, и на момент выполнения строки 2 вы будете пытаться вызвать деструктор типа std::vector<double> для типа std::vector<int>.
Выводы главы
Повторный вызов деструктора - UB
Деструктор неявно вызывается в конце скоупа для автоматической переменной, в конце жизни программы для статической переменной и в конце жизни потока для
thread_localпеременнойДеструкторы есть только у классов
У скаляров есть псевдо-деструкторы
Псевдо-деструкторы не вызываются неявно
Тривиальные деструкторы не имеют логики/кода в своем теле. Псевдо-деструкторы тоже обладают свойствами тривиальности, хотя и не являются деструкторами в нормальном понимании
Стандарту еще есть куда расти в описании связи деструктора и конца времени жизни объекта. На текущий момент есть много недосказанного, а некоторые параграфы из [basic.life] имеют тенденцию переписываться от версии к версии
Есть смысл полагаться только на проверенные временем железобетонные правила о деструкторах и не сходить на территорию, где вы получите “UB Шредингера” - сегодня у стандарта нет для вас UB; завтра UB есть
Не спекулируйте на тривиальных деструкторах - они пустые, но все равно вызываются и все равно уничтожают объект
Не уничтожайте руками переменные на стеке, статические переменные и
thread_localпеременные. Исключение - provides storage типы. Для обычных типов безопасно уничтожать вручную объект если вы выделили его на куче
Конец
Эта статья, в первую очередь, нужна была мне самому. Если она что-то прояснила еще для кого-то - тем приятнее. Если вы нашли вопиющую ошибку - пишите/звоните мне срочно. Если вы нашли мелкий недочет - пишите, но не срочно.
domix32
Неясно что вы называете "создаться". Формально оно уже создано, вопрос только как вы будете интерпретировать созданный объект. Вы можете хоть десять раз подряд кастить типы между собой и менять в соответсвии с полями - пока вы в пределах выбранной памяти можно писать и переписывать любые данные.
Конструирование/деструкция же типа это просто (де)инициализация полей в определённом порядке и ассоциирование некоторой логики после инициализации полей.
Так ваш первый "недостаточный" пример Foo выглядел бы так.
Единственная разница - при нехватке памяти
mallocвернётnullptr, аnew Fooвыкинет исключениеbad_alloc. Ну и если мы хотим явно привязать какую-то логику после конструирования, то мы её просто инкапсулируем в пределах конструктора, в то время как в Си это делается посредством джентельменского соглашения или заворачивание этой логики в какой-нибудьfoo_new()иfoo_free(), что суть то же соглашение.Обращение к неинициализированным полям в обоих ситуациях остаётся UB.
опять же - нет. созданы они строчкой выше, на моменте где вы добыли себе байты. Объявление лайфтайма фактически тот же reinterpreter_cast, но семантически чище. Инициализации типов или выделений памяти на этом этапе не происходит. Отсюда и проблема с инициализациие лайфтаймов подобъектов.
ничего не делать, т.к. формально состояние созданного объекта уже ill-former уже после объявление начала времени жизни и дальнейшии манипуляции с ним становятся UB. Поэтому
s->i = 3;тоже будет UB, т.к. необходимый инвариант уже нарушен. Если вы уверены в поведении вашего компилятора, то с этим в теории можно работать, но без гарантий, как и с любым UB.картинка врёт. t2 после std::lauder не будет иметь отдельного объекта и должно ссылаться на начало Wrapper.
В остальном - отличный ликбез