вторник, 20 августа 2024 г.

[prog.c++] Вынесу из комментариев на Хабре про дебилизм с std::launder и std::start_lifetime_as

В комментариях к статье на Хабре зашел разговор об уместности использования std::launder и std::start_lifetime_as.

И, насколько я смог понять из разговора, вот в такой ситуации у нас нет сейчас простого и понятного способа в точке (1) сделать каст указателя к Demo*:

#include <iostream>
#include <functional>
#include <new>
#include <cstddef>
#include <cstring>
#include <memory>

struct Data {
    int _a{0};
    int _b{1};
    int _c{2};
};

using CreateFn = std::function<void(std::byte*)>;

void make_and_use_object(CreateFn creator) {
    alignas(Data) std::byte buffer[sizeof(Data) * 3];
    std::byte * raw_ptr = buffer + sizeof(Data);
    creator(raw_ptr);
    Data * obj = std::launder(reinterpret_cast<Data *>(raw_ptr)); // (1)
    std::cout << "obj: " << obj->_a << ", " << obj->_b << ", " << obj->_c << std::endl;
}

int main() {
    make_and_use_object([](std::byte * ptr) {
        new(ptr) Data{._a = 1, ._b = 2, ._c = 3};
    });
    make_and_use_object([](std::byte * ptr) {
        Data data{ ._a = 25, ._b = 16, ._c = 890};
        std::memcpy(ptr, &data, sizeof(data));
    });
}

Суть в том, что когда make_and_use_object вызывается с первой лямбдой и по указателю ptr новый объект Data создается через placement new, то затем raw_ptr нельзя просто так скастить к Data*. Тут требуется именно std::launder. Ну вот требуется и все. Иначе UB.

Тогда как при использовании второй лямбды в точке (1) вообще не нужно вызывать ни std::launder, ни std::start_lifetime_as. Вроде как все дело в том, что std::memcpy неявно начинает время жизни нового объекта. И результирующий указатель можно просто скастить к Data* и все.

Но, допустим, что во второй лямбде я не использую std::memcpy, а заполняю переданный мне буфер собственной функцией побайтового чтения из COM-порта. Что-то вроде:

 make_and_use_object([](std::byte * ptr) {
     com_port_reader reader;
     reader.init();
     while(reader.has_data()) {
         *ptr = reader.read_next_byte();
         ++ptr;
     }
 });

Естественно, ничего из этого в стандарте не описано и компилятор понятия не имеет, начинается ли здесь какой-нибудь lifetime или нет.

Соответственно, что делать в make_and_use_object после завершения работы лямбда-функции?

Если содержимое объекта внутри буфера было сформировано побайтовым чтением из COM-порта, то std::launder не поможет, тут нужен как раз std::start_lifetime_as (который завезли в C++23, но который, если не ошибаюсь, пока нигде не реализован).

Т.е. мы оказываемся в ситуации, когда у нас есть корректно размещенный в памяти байтовый буфер, внутри которого лежит корректно сформированное значение объекта. Но мы не можем одним простым действием взять и получить легальный указатель на этот объект.

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

Для полноты картины: там же в комментариях указали на наличие пропозала от Антона Полухина. Смысл этого пропозала -- отказаться от необходимости использовать std::launder вот в таких простых ситуациях:

alignas(T) std::byte storage[sizeof(T)];
auto* p1 = ::new (&storage) T();
auto* p2 = reinterpret_cast<T*>(&storage);
bool b = p1 == p2;  // b will have the value true.

Если этот пропозал примут, то ситуация окажется совсем веселая:

  • до C++26 подобный кастинг без std::launder будет считаться UB. Т.е. если вы пишете под C++17 или C++20, то должны использовать std::launder, иначе в вашем коде формальный UB;
  • начиная с C++26 это уже не UB и можно std::launder не писать.

А теперь представим проект (какую-нибудь библиотеку, вроде RapidJSON), который должен собираться и под C++14, и под C++17, и под C++20, и под C++23, и под C++26. И как в таком проекте быть со всеми этими std::launder и std::start_lifetime_as? Кроме как прятать подобные фокусы за фасадом макросов мне ничего в голову не приходит.

Но пусть даже у нас есть набор нужных вспомогательных макросов... Вернемся к самому первому примеру в посте. Как там понять, требуется ли std::launder, std::start_lifetime_as или же вообще ничего не требуется?

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

Собственно, чего хотелось бы иметь:

  • чтобы std::launder оставался только для случаев, когда пересоздается объект. Т.е. был объект типа A и на него были указатели, затем на том месте, где был объект A, создали новый объект A (или даже какой-то отнаследованный от него B -- пример), старые указатели "протухли", нужно их "отмыть" через std::launder. Все. Больше ни для чего std::launder не нужен;
  • чтобы std::start_lifetime_as использовался для случая, когда у нас есть std::byte* или char*, и мы хотим сказать компилятору, что по этому указателю реально живет объект A.

И строго так, без всяких неявных умолчаний, что мол memcpy или malloc начинает время жизни.

Но, боюсь, комитет по стандартизации под давлением разработчиков компиляторов добавит нам еще массу ярких ощущений в борьбе с UB. Особенно с такими, про которые ты даже не догадываешься.

PS. Да, я в курсе, что практику с неявным началом времени жизни при использовании ряда функций (вроде malloc или memcpy) в C++20 ввели для того, чтобы узаконить говнокод, написанный в древности или даже вообще на чистом Си (как в случае с GCC -- сперва это был Сишный код, а потом еще стали компилировать как C++). Но ведь C++ все равно потихоньку отказывался от атавизмов, например, ключевое слово auto кардинально поменяло свой смысл, а ключевое слово register сейчас нельзя использовать по его первоначальному назначению. Так что лично для меня этот аргумент из категории "ну такое себе". А если какие-то комитетчики настаивают на то, что этот говнокод нужно оставить как есть и сделать легальным (да еще и за счет умолчаний, про которые мало кто знает), то хочется сказать таким комитетчикам: ну так оставьте легальным и тот говнокод, в котором std::launder не используется.

PPS. На всякий случай ссылки на посты двухгодичной давности, в которых обсуждалась проблематика std::launder: "Продолжение темы про передачу C++объектов через shared memory. Промежуточные выводы" и "В склерозник: ссылки на тему std::launder"