В продолжение недавно начатой темы. Есть очень удобная идиома copy-then-swap, которая позволяет легко и просто написать для своего типа оператор копирования, обеспечивающий строгую гарантию безопасности исключений.
Для примера рассмотрим некий вымышленный тип, который содержит внутри пару векторов:
|
class special_container { struct description { ... }; struct payload { ... }; std::vector<description> m_descriptions; std::vector<payload> m_payloads; ... }; |
И мы хотим, чтобы у special_container был оператор копирования со строгой гарантией безопасности исключений. Для этого нам потребуются:
- обычный конструктор копирования;
- не бросающий исключений swap.
что достигается весьма просто:
|
class special_container { ... public: // Swap сделаем через свободную функцию. friend void swap(special_container & a, special_container & b) noexcept { using std::swap; swap(a.m_descriptions, b.m_descriptions); swap(a.m_payloads, b.m_payloads); } // Конструктор копирования. special_container(const special_container & other) : m_descriptions{ other.m_descriptions } , m_payloads{ other.m_payloads } {} ... }; |
Имея в своем распоряжении эти базовые инструменты можно сделать и оператор копирования:
|
special_container & special_container::operator=(const special_container & other) { special_container tmp{ other }; swap(*this, tmp); return *this; } |
Фокус здесь в том, что возможные исключения вылетят при формировании объекта tmp. Но при этом ничего не меняется в this. А если при конструировании tmp исключений не случилось, то мы заменяем содержимое this содержимым tmp.
Еще один приятный фокус в том, что такая примитивная реализация прекрасно защищает и от присваивания самому себе. Впрочем, если экземпляры special_container "тяжелые", а вероятности самоприсваивания не нулевая, то можно и по старинке:
|
special_container & special_container::operator=(const special_container & other) { if(this != std::addressof(other)) { special_container tmp{ other }; swap(*this, tmp); } return *this; } |
Пока что все идет замечательно.
Но давайте представим себе, что нам потребовалось научить special_container работать с разными аллокаторами. Т.е. тип special_container превращается во что-то вроде:
|
template<typename Alloc> class special_container { struct description {}; struct payload {}; using alloc_traits = std::allocator_traits<Alloc>; using description_allocator = alloc_traits::template rebind_alloc<description>; using payload_allocator = alloc_traits::template rebind_alloc<payload>; std::vector<description, description_allocator> m_descriptions; std::vector<payload, payload_allocator> m_payloads; ... }; |
Сможем ли мы и дальше пользоваться идиомой copy-then-swap?
И вот тут у меня есть сомнения. А в попытках разобраться как раз и получился этот пост.
У аллокатора может быть такое свойство как propagate_on_container_swap. Если это свойство выставлено в std::true_type, то при выполнении swap мы можем обменять аллокаторы для контейнеров.
Грубо говоря, допустим, что у нас есть собственный тип аллокатора:
|
class special_allocator { ... public: // Указываем, что обмен аллокаторами поддерживается. struct propagate_on_container_swap : public std::true_type {}; // Явно говорим, что экземпляры аллокаторов отличаются. struct is_always_equal : public std::false_type {}; ... }; |
В этом случае мы можем сделать вот так:
|
special_allocator first_allocator{...}; special_allocator second_allocator{...}; assert(first_allocator != second_allocator); std::vector<int, special_allocator> first{ first_allocator }; std::vector<int, special_allocator> second{ second_allocator }; ... swap(first, second); |
Т.е. пока что все вроде бы понятно и логично.
Но что, если propagate_on_container_swap определен как std::false_type (или не определен, что приравнивается к std::false_type)?
В этом случае обмен аллокаторами при выполнении swap невозможен.
Более того, если у контейнеров аллокаторы не равны, то выполнение swap уже является undefined behaviour. Т.е. вот в этом случае:
|
class special_allocator { ... public: // Указываем, что обмен аллокаторами НЕ поддерживается. struct propagate_on_container_swap : public std::false_type {}; // Явно говорим, что экземпляры аллокаторов отличаются. struct is_always_equal : public std::false_type {}; ... }; std::vector<int, special_allocator> first{ first_allocator }; std::vector<int, special_allocator> second{ second_allocator }; assert(first_allocator != second_allocator); ... swap(first, second); |
Мы получаем UB.
И знаете, что интересно?
Что у штатных аллокаторов в стандартной библиотеке С++, например, у std::allocator и std::pmr::polymorphic_allocator свойство propagate_on_container_swap выставлено в std::false_type.
И если для std::allocator это компенсируется значением is_always_equal, то вот для std::pmr::polymorphic_allocator еще и is_always_equal является std::false_type.
Получается, что как только я делаю special_container шаблоном, параметризуемым типом аллокатора, то реализация оператора копирования через идиому copy-then-swap становится некорректной.
Давайте взглянем на нее еще раз:
|
template<typename Alloc> special_container<Alloc> & special_container<Alloc>::operator=(const special_container<Alloc> & other) { special_container tmp{ other }; // (1) swap(*this, tmp); // (2) return *this; } |
Здесь в точке (1) мы получаем новый контейнер, у которого будет собственный аллокатор. И этот аллокатор может отличаться от аллокатора, который есть у this.
Соответственно, операция swap может вести к UB если:
- у аллокатора свойство propagate_on_container_swap -- это std::false_type;
- у аллокатора свойство is_aways_equal -- это std::false_type;
- у tmp и this не равные друг другу экземпляры аллокаторов.
Т.е. вот в этом случае:
|
class special_allocator { ... public: // Указываем, что обмен аллокаторами НЕ поддерживается. struct propagate_on_container_swap : public std::false_type {}; // Явно говорим, что экземпляры аллокаторов отличаются. struct is_always_equal : public std::false_type {}; ... }; special_allocator first_allocator{...}; special_allocator second_allocator{...}; assert(first_allocator != second_allocator); special_container<special_allocator> first{ first_allocator }; special_container<special_allocator> second{ second_allocator }; ... // Наполнение контейнеров. first = second; // (3) |
в точке (3) у нас будет UB из-за использования swap-а в реализации operator= для special_container.
Как я смог понять, выход в том, чтобы при создании tmp внутри operator= явным образом задать для tmp нужный allocator. Что-то вроде:
|
template<typename Alloc> class special_container { struct description {}; struct payload {}; using alloc_traits = std::allocator_traits<Alloc>; using description_allocator = alloc_traits::template rebind_alloc<description>; using payload_allocator = alloc_traits::template rebind_alloc<payload>; // Аллокатор, который мы будем использовать. Alloc m_allocator; std::vector<description, description_allocator> m_descriptions; std::vector<payload, payload_allocator> m_payloads; ... public: // Специальный вариант конструктора копирования, который получает // экземпляр аллокатора. special_container( // Откуда берем содержимое. const special_container & other, // Аллокатор, который должны использовать. const Alloc & allocator) : m_allocator{ allocator } , m_descriptions{ other.m_descriptions, m_allocator } , m_payloads{ other.m_payloads, m_allocator } {} ... // Оператор копирования. special_container & operator=(const special_container & other) { // Делаем копию other, но с использованием нашего аллокатора. special_container tmp{ other, m_allocator }; // Теперь безопасно делаем swap, т.к. даже если у аллокатора // propagate_on_container_swap -- это std::false_type, то все // равно аллокатор в tmp и в this один и тот же. swap(*this, tmp); } }; |
И тут, казалось бы, можно было бы поставить точку и выдохнуть. Если бы не одно "но": у аллокатора есть свойство propagate_on_container_copy_assignment. Если оно соответствует std::true_type, то при копировании контейнеров должно происходить копирование аллокатора. Т.е. в этом случае мы не можем создать tmp с использованием старого аллокатора.
И тут начинает казаться, что с учетом свойства propagate_on_container_copy_assignment проще делать оператор копирования без идиомы copy-then-swap:
|
template<typename Alloc> special_container<Alloc> & special_container<Alloc>::operator=(const special_container<Alloc> & other) { if(this != std::addressof(other)) { m_descriptions = other.m_descriptions; // (4) m_payloads = other.m_payloads; } return *this; } |
Ведь в этой тривиальной реализации все должно бы произойти автоматически: если propagate_on_container_copy_assignment предписывает копировать аллокатор, то это произойдет автоматически при копировании m_descriptions и m_payloads. А если копировать аллокаторы не нужно, то это также автоматически будет учтено в тех же самых копированиях.
Однако, при этом мы имеем только базовую гарантию безопасности исключений. И достаточно специфическую. Давайте представим себе, что аллокатор требует своего копирования (т.е. propagate_on_container_copy_assignment -- это std::true_type). И исключение у нас происходит в точке (4).
В этом случае в m_descriptions, скорее всего, уже будет аллокатор из other. Но в каком состоянии содержимое m_descriptions -- мы толком не знаем. Старого содержимого там, наверняка, уже нет. А вот что там из нового -- непонятно.
Тогда как m_payloads мы тронуть еще не успели и он остался в своем исходном виде. Со своим первоначальным аллокатором.
Как по мне, так получить экземпляр special_container в таком неопределенном состоянии -- это то еще удовольствие. Поэтому есть ощущение, что можно было бы сделать так:
|
template<typename Alloc> class special_container { struct description {}; struct payload {}; using alloc_traits = std::allocator_traits<Alloc>; using description_allocator = alloc_traits::template rebind_alloc<description>; using payload_allocator = alloc_traits::template rebind_alloc<payload>; // Содержимое контейнера. Собрано в одном месте дабы проще // было его полностью заменять. struct content { // Аллокатор, который мы будем использовать. Alloc m_allocator; std::vector<description, description_allocator> m_descriptions; std::vector<payload, payload_allocator> m_payloads; content(const Alloc & alloc) : m_allocator{ alloc } , m_descriptions{ alloc } , m_payloads{ alloc } {} content(const Alloc & alloc, const std::vector<description, description_allocator> & descriptions, const std::vector<payload, payload_allocator> & payloads) : m_allocator{ alloc } , m_descriptions{ descriptions, alloc } , m_payloads{ payloads, alloc } {} // Для перемещения нам достаточно того, что сгенерирует компилятор. content(content && other) noexcept = default; }; content m_content; ... public: // Выполнение требований к allocator aware container. using allocator_type = Alloc; [[nodiscard]] allocator_type get_allocator() const { return m_content.m_allocator; } // Специальный вариант конструктора копирования, который получает // экземпляр аллокатора. special_container( // Откуда берем содержимое. const special_container & other, // Аллокатор, который должны использовать. const Alloc & allocator) : m_content{ allocator, other.m_descriptions, other.m_payloads } {} ... // Оператор копирования. special_container & operator=(const special_container & other) { if(this != std::addressof(other)) { // Определяем какой именно аллокатор должен использоваться в копии. const auto & new_copy_alloc = alloc_traits::propagate_on_container_copy_assignment::value ? other.get_allocator() // Нужно забирать из other. : this->get_allocator() // Нужно оставлять свой. ; // Первая часть copy-then-swap. content new_content{ new_copy_alloc, other.m_descriptions, other.m_payloads }; // // ВАЖНО: ниже по коду исключений уже не ждем. // // Вторая часть copy-then-swap состоит из уничтожения текущего content... m_content.~content(); // ...и его пересоздания по месту. new(m_content) content{ std::move(new_content) }; } return *this; } }; |
Понятное дело, что в таком случае придется всю работу с m_content в special_container делать через std::launder. Но это уже дело техники.
При таком подходе, как мне думается, как раз получается обеспечить строгую гарантию безопасности исключений для операции присваивания даже при необходимости замены аллокатора. При этом мы также достигаем и выполнения требования стандарта (т.е. свойство propagate_on_container_copy_assignment корректно обрабатывается).
Отдельным моментом стоит использование идиомы move-then-swap в реализации оператора перемещения.
Пока мы не связываемся с аллокаторами, у нас нет проблем с оператором перемещения, который красиво и просто делается через move-then-swap:
|
special_container::special_container(special_container && other) noexcept : m_descriptions{ std::exchange( other.m_descriptions, std::vector<description>{}) } , m_payloads{ std::exchange( other.m_payloads, std::vector<payload>{}) } {} special_container & special_container::operator=(special_container && other) noexcept { special_container tmp{ std::move(other) }; swap(*this, tmp); return *this; } |
Но вот как только в дело вступают аллокаторы, так все становится гораздо интереснее.
Есть ощущение, что в таком случае будет что-то вроде:
|
template<typename Alloc> class special_container { struct description {}; struct payload {}; using alloc_traits = std::allocator_traits<Alloc>; using description_allocator = alloc_traits::template rebind_alloc<description>; using payload_allocator = alloc_traits::template rebind_alloc<payload>; // Можно ли делать нормальный не бросающий move. using is_nonthrowing_move_possible = std::conditional_t< alloc_traits::propagate_on_container_move_assignment::value || alloc_traits::is_always_equal::value, std::true_type, std::false_type>; // Аллокатор, который мы будем использовать. Alloc m_allocator; // Содержимое контейнера. std::vector<description, description_allocator> m_descriptions; std::vector<payload, payload_allocator> m_payloads; ... public: ... // Конструктор перемещения. // Вполне достаточно того, что генерирует компилятор. special_container(special_container & other) noexcept = default; // Оператор перемещения. special_container & operator=(special_container && other) noexcept( is_nonthrowing_move_possible::value ) { // Вот здесь нам обязательно нужна защита от самоприсваивания. if(this != std::addressof(other)) { // Замена аллокатора только если это разрешается самим аллокатором. if constexpr(alloc_traits::propagate_on_container_move_assignment::value) { // Эта штука, по идее, бросать исключения не должна. m_allocator = std::move(other.m_allocator); } // А вот здесь у нас либо исключений не будет, либо они возможны, // но все это обрабатывается внутри std::vector::operator=. m_descriptions = std::move(other.m_descriptions); m_payloads = std::move(other.m_payloads); } return *this; } }; |
Спасибо всем, кто дочитал до финала. Приношу свои извинения за такой объем сумбурного текста без четко оформленных выводов. В этом посте я пытался зафиксировать свои мысли по ходу изучения вопроса реализации собственного AllocatorAwareContainer-а. И несмотря на все написанное выше у меня пока что нет ощущения, что удалось это сделать.
Буду признателен, если в моих рассуждениях обнаружатся ошибки или недоработки, на которые мне любезно укажут.
Комментариев нет:
Отправить комментарий