Lite_stream
@Lite_stream

Assignment operator VS Destructor + Placement new, где аргумент placement new — prvalue?

Всем привет

Является ли хорошей практикой, использовать деструктор по адресу + placement new, нежели чем оператор присваивания и временный объект ?
Пример кода (здесь buffer это std::byte):

template<typename ...Args>
    void add(Args &&... args)
    {
        if (size < maxSize)
        {
            new (&buffer[sizeof(T) * head]) T(std::forward<Args>(args)...);

            head = (head + 1) % maxSize;
            ++size;
            return;
        }
        
// Assignment operator
//        (*(reinterpret_cast<T *>(&buffer[sizeof(T) * head]))) = T(std::forward<Args>(args)...);

        // Destructor + Placement new
        int32_t index = sizeof(T) * head;
        (reinterpret_cast<T *>(&buffer[index]))->~T();
        new (&buffer[index]) T(std::forward<Args>(args)...);

        head = (head + 1) % maxSize;
    }


В варианте с оператором присваивания будет вызвано 3 метода:
1. Конструктор - сначала создаётся временный объект Т
2. (Мувающий, если мув оператор присуствует (т.к. T(std::forward(args)...) - prvalue)) Оператор присваивания - передаётся объект с 1го шага в уже существующий объект
3. Деструктор - Объект, созданный на 1м шаге разруюшается

В варианте с деструктором + placement new:
1. Деструктор объекта, который должен быть уничтожен
2. Конструктор через placement new нового объекта
  • Вопрос задан
  • 188 просмотров
Решения вопроса 1
@MarkusD Куратор тега C++
все время мелю чепуху :)
К современному коду предъявляется немало требований. В частности, код должен отвечать определенным критериям качества чтобы иметь шансы на добавление в основной репозиторий в качестве решения задачи.
Среди таких критериев качества нередко бывают: соответствие общему стилю кода, соответствие общему стилю форматирования, наличие документации или самодокументируемость, а так же - элементарная понятность для читателя.

Читатель современного кода не должен задаваться вопросами: что тут делает ядовитый газ, зачем швабры и почему вентилятор такой большой [?].
Читателю все должно быть понятно практически сразу, у него должно возникать как можно меньше вопросов.

Вопросы у читателя возникают тогда, когда он видит что-то нелогичное конкретно для данного участка кода.
Когда читатель изучает код циклического буфера, он у себя в голове держит правило, согласно которому добавляемые элементы будут или добавлены в незанятую память, или будут замещать уже расположенные в памяти элементы.
Эту логику для читателя лучше не ломать, а то пойдут вопросы. Поэтому, самым логичным образом будет именно замещать состояние уже размещенного объекта новым используя оператор перемещения.

*reinterpret_cast<T*>( &buffer[ sizeof( T ) * head ] ) = T{ std::forward<Args>( args )... };

Да, тут присутствует Value Initialization и оператор перемещения. Однако, оптимизация этого кода сведет все к довольно короткому и как можно более быстрому коду. Поэтому не стоит беспокоиться об этом на текущем этапе. Лучше беспокоиться о понятности кода для читателя.
Столь же понятным будет и такой код:
auto T vicar{ std::forward<Args>( args )... };
std::swap( *reinterpret_cast<T*>( &buffer[ sizeof( T ) * head ] ), vicar );

Он не будет вызывать много вопросов, разве только вопрос относительно использования std::swap, но только от людей со слабой привычкой пользоваться STL.

Однако, такой код может привести к ошибке трансляции в том случае, если T является неперемещаемым. В твоем ЦБ должны присутствовать проверки на перемещаемость, копируемость и возможность размена состояний.
Если T можно копировать, но не перемещать, использовать стоит оператор копирования.
И только если ни копировать, ни перемещать T нельзя, следует пользоваться деструктором и размещающим конструктором.

Я бы рекомендовал все три действия оформить в виде перегрузок шаблона функции со SFINAE, чтобы для любого T включалась только одна конкретная перегрузка шаблона.
Ответ написан
Пригласить эксперта
Ответы на вопрос 1
@dolusMalus
С++ programmer from gamedev
Мне кажется, что данный вопрос стоит рассмотреть с точки зрения нескольких позиций:
  1. Безопасность относительно исключений. Какой уровень гарантий должен предоставлять данный метод?
  2. Производительность. Критично ли получить максимально эффективный код или на данном этапе этим можно пожертвовать?
  3. Оценить проектное решение в свете известных и проверенных практик, например, принципы SOLID


Для начала определяемся используются ли вообще исключения в Вашем проекте и если нет, то можно переходить сразу к вопросу о производительности. В противном случае, рассмотрим каждый случай:
int32_t index = sizeof(T) * head;
        (reinterpret_cast<T *>(&buffer[index]))->~T();
        new (&buffer[index]) T(std::forward<Args>(args)...);
        head = (head + 1) % maxSize;

данный вариант не предоставляет даже базовых гарантий (basic exception guarantee), т.к. если конструктор сгенерирует исключение, то мы уже закончили время жизни объекта вызвав деструктор, но не изменятся size и head; а значит мы пришли в невалидное состояние.

int32_t index = sizeof(T) * head;
        (reinterpret_cast<T *>(&buffer[index]))->~T();
        new (&buffer[index]) T(std::forward<Args>(args)...);
        head = (head + 1) % maxSize;

Можем сделать развилку по noexcept для конструктора с помощью SFINAE или if constexpr и в случае, если конструктор является noexcept; то оставить данный код. В противном случае придется уже действовать в зависимости от необходимого уровня гарантий, однако решения будут достаточно громоздкие: в добавок к развилке надо будет ловить исключение, пробрасывать его дальше, при этом восстанавливать объект или уменьшать счетчики и т.п. Более того можно вообще не обеспечить сильную гарантию при определенных условиях. Как видите, это уже сильно осложнило решение.
Теперь к другому варианту:
// Assignment operator
//        (*(reinterpret_cast<T *>(&buffer[sizeof(T) * head]))) = T(std::forward<Args>(args)...);

Здесь ситуация относительно лучше, т.к. проблемы могут быть только на уровне оператора присваивания, что как минимум перекладывает часть ответственности в сторону автора типа T. Однако воспользовавшись решением от Евгений Шатунов можно относительно легко получить достаточно понятный и "чистый" код на уровне сильных гарантий. Также стоит посмотреть в сторону copy and swap идиомы, как близкой к данной проблеме.
По итогу при необходимости строгих гарантий стоит отдать предпочтение варианту с временным объектом.

С точки зрения производительности, если нет необходимости в максимальной оптимизации, то стоит отдать предпочтение более понятному коду. Данный момент уже прекрасно освещен в ответе Евгений Шатунов, поэтому не вижу смысла повторяться. Однако формально если отбросить предположения об оптимизациях, то вариант с реконструированием по месту (деструктор -> конструктор) оптимальней, т.к. гарантировано не требует выделения дополнительных ресурсов от временного объекта и только две операции + нет необходимости в относительно сложном анализе на перемешаемость/копируемость. В случае со swap, мы можем таки попасть на копирование в зависимости от перемещаемости типа T.

И часто забываемый, но крайне важный пункт про проектирование. Здесь нарушен Single responsibility принцип, что возможно и породило этот вопрос. Т.е. у Вас метод по добавлению элемента может удалять/заменять элементы, что должно вызвать вопросы. Более того, вы решили за клиента вашего API (даже если это и Вы сами) как нужно обрабатывать исключительную ситуацию по переполнению. Потом например вы решите, что в одном месте стоит сложить старые элементы в отдельную очередь в другом залогировать или удалять не по одному, а сразу половину буффера. Все это потребует переписывания метода add, а зачем и почему? Попробуйте убрать эту часть кода заменив на выбрасывание исключения или вариант с возвращением успешности операции (менее грамотное решение, но это уже из области субъективной оценки) и посмотреть как увеличится прозрачность и простота написания клиентского кода для этого метода. Еще стоит посмотреть на сходное проектное решение в std::vector и его методе pop_back. Подумайте почему он не возвращает удаленный элемент?

Итого, если важна производительность и не важна работа с исключениями; то разумно выбрать вариант с реконструированием; иначе обмен с временным объектом. Но не стоит забывать всегда про анализ проектного решения и правильную ли Вы проблему вообще решаете.
Ответ написан
Ваш ответ на вопрос

Войдите, чтобы написать ответ

Войти через центр авторизации
Похожие вопросы