Этот урок основан на уроке о спецификациях исключений и спецификаторе noexcept в С++, где мы рассматривали строгую гарантию исключений, которая гарантирует то, что если работа функции прерывается исключением, то не произойдет утечки памяти и состояние программы не будет изменено. В частности, все конструкторы должны придерживаться строгой гарантии исключений, чтобы остальная часть программы не оставалась в измененном состоянии, если создание объекта завершится неудачей.
Проблемы с исключениями при их совместном использовании с конструкторами перемещения
Рассмотрим случай, когда мы копируем какой-то объект, и копирование по какой-то причине дает сбой (например, не хватает памяти). В таком случае копируемому объекту не причиняется никакого вреда, поскольку исходный объект не нуждается в модификации для создания копии. Мы можем отбросить неудавшуюся копию и двигаться дальше. Строгая гарантия исключений сохраняется.
Теперь рассмотрим случай, когда мы вместо копирования перемещаем объект. Операция перемещения передает право собственности на используемый ресурс от источника к целевому объекту. Если операция перемещения прерывается исключением после того, как происходит передача права собственности, то наш исходный объект останется в измененном состоянии. Это не проблема, если исходный объект является временным объектом и будет в любом случае отброшен после перемещения, но для объектов, не являющихся временными, это — проблема, т.к. мы повредили исходный объект. Для соблюдения правил строгой гарантии исключений, нам нужно было бы переместить используемый ресурс обратно в исходный объект, но если перемещение не удалось в первый раз, то нет никакой гарантии, что и обратное перемещение будет успешным.
Каким образом для конструкторов перемещения можно обеспечить выполнение строгой гарантии исключений? Для этого достаточно избегать создания исключений в теле конструктора перемещения. Но конструктор перемещения может вызывать другие конструкторы, которые потенциально способны генерировать исключения. Возьмем, к примеру, конструктор перемещения для std::pair
, который должен попытаться переместить каждый подобъект в исходной паре в новый объект:
1 2 3 4 5 6 7 |
// Пример определения конструктора перемещения для std::pair. // Берем 'старую' пару объектов, и при помощи конструктора перемещения создаем новую пару объектов, в которой 'первый' и 'второй' объекты получены из 'старых' template <typename T1, typename T2> pair<T1,T2>::pair(pair&& old) : first(std::move(old.first)), second(std::move(old.second)) {} |
Мы будем использовать два класса: MoveClass и CopyClass, из которых создадим пару, чтобы продемонстрировать проблему строгой гарантии исключений при работе с конструкторами перемещения:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 |
#include <iostream> #include <utility> // для работы std::pair, std::make_pair, std::move, std::move_if_noexcept #include <stdexcept> // std::runtime_error #include <string> #include <string_view> class MoveClass { private: int* m_resource{}; public: MoveClass() = default; MoveClass(int resource) : m_resource{ new int{ resource } } {} // Конструктор копирования MoveClass(const MoveClass& that) { // Глубокое копирование if (that.m_resource != nullptr) { m_resource = new int{ *that.m_resource }; } } // Конструктор перемещения MoveClass(MoveClass&& that) : m_resource{ that.m_resource } { that.m_resource = nullptr; } ~MoveClass() { std::cout << "destroying " << *this << '\n'; delete m_resource; } friend std::ostream& operator<<(std::ostream& out, const MoveClass& moveClass) { out << "MoveClass("; if (moveClass.m_resource == nullptr) { out << "empty"; } else { out << *moveClass.m_resource; } out << ')'; return out; } }; class CopyClass { public: bool m_throw{}; CopyClass() = default; // Конструктор копирования выбрасывает исключение при выполнении копирования из объекта CopyClass, где его переменная m_throw имеет значение 'true' CopyClass(const CopyClass& that) : m_throw{ that.m_throw } { if (m_throw) { throw std::runtime_error{ "abort!" }; } } }; int main() { // Мы можем создать объект std::pair без каких-либо проблем std::pair my_pair{ MoveClass{ 13 }, CopyClass{} }; std::cout << "my_pair.first: " << my_pair.first << '\n'; // Но проблемы начинают появляться, когда мы пытаемся переместить объекты одной пары в другую пару try { my_pair.second.m_throw = true; // чтобы спровоцировать генерацию исключения конструктором копирования // Следующая строка выбросит исключение std::pair moved_pair{ std::move(my_pair) }; // мы закомментируем эту строку чуть позже // std::pair moved_pair{std::move_if_noexcept(my_pair)}; // мы раскомментируем эту строку чуть позже std::cout << "moved pair exists\n"; // никогда не выведется } catch (const std::exception& ex) { std::cerr << "Error found: " << ex.what() << '\n'; } std::cout << "my_pair.first: " << my_pair.first << '\n'; return 0; } |
Результат выполнения программы:
destroying MoveClass(empty)
my_pair.first: MoveClass(13)
destroying MoveClass(13)
Error found: abort!
my_pair.first: MoveClass(empty)
destroying MoveClass(empty)
Давайте разберем результат вывода построчно:
Cтрока №1: Здесь мы видим, что временный объект класса MoveClass, используемый для инициализации my_pair
, уничтожается сразу же после выполнения стейтмента создания экземпляра my_pair
. Он пуст (empty
), так как подобъект класса MoveClass в my_pair
был создан из него перемещением, о чем свидетельствует следующая строка вывода.
Строка №2: Здесь мы видим, что my_pair.first
содержит объект класса MoveClass со значением 13
.
Строка №3: Мы создали moved_pair
, копируя его подобъект класса CopyClass (у него нет конструктора перемещения), но эта конструкция копирования вызвала исключение, так как мы изменили логический флаг. Построение объекта moved_pair
было прервано генерацией исключения, и его уже созданные члены были уничтожены. В этом случае член класса MoveClass был уничтожен, подтверждением чего является вывод строки destroying MoveClass(13)
.
Строка №4: Далее мы видим сообщение Error found: abort!
из функции main().
Строка №5: Когда мы опять пытаемся вывести на экран my_pair.first
, мы снова видим, что член класса MoveClass пуст (empty
). Поскольку объект moved_pair
был инициализирован с помощью функции std::move(), то член класса MoveClass (который имеет конструктор перемещения) был перемещен для создания moved_pair
, и объект my_pair.first
был обнулен.
Строка №6: В завершении, объект my_pair
уничтожается в конце функции main().
Итак, подводя итог вышеописанных результатов, можно заключить, что конструктор перемещения std::pair использовал выбрасывающий исключение конструктор копирования класса CopyClass. Этот конструктор копирования выбрасывает исключение, вызывающее прерывание создания объекта moved_pair
, из-за чего объект my_pair.first
постоянно повреждается. Строгая гарантия исключений не выполняется.
На помощь спешит функция std::move_if_noexcept()
Обратите внимание, что вышеописанной проблемы можно было бы избежать, если бы std::pair попытался сделать копию вместо перемещения. В таком случае объект moved_pair
не удалось бы создать, но объект my_pair
не был бы изменен.
Но за копирование вместо перемещения приходится расплачиваться производительностью, даже для тех объектов, которые этого и не требуют. В идеале мы бы хотели выполнить перемещение, если это возможно сделать безопасно, и копирование — в противном случае.
К счастью, в C++ есть два механизма, которые при совместном использовании позволяют нам это выполнить. Во-первых, поскольку noexcept-функции являются функциями без исключений/сбоев, то они неявно удовлетворяют критериям строгой гарантии исключений. Таким образом, noexcept-конструктор перемещения гарантированно завершит свою работу успешно.
Во-вторых, мы можем использовать функцию std::move_if_noexcept() из Стандартной библиотеки С++, чтобы определить, следует ли выполнять перемещение или копирование. Функция std::move_if_noexcept() является аналогом функции std::move() и используется таким же образом.
Если компилятор может определить, что объект, переданный в качестве аргумента для std::move_if_noexcept(), не будет выбрасывать исключение при применении конструктора перемещения (или если объект является только перемещаемым и не имеет конструктора копирования), то std::move_if_noexcept() будет работать идентично std::move() (и вернет объект, преобразованный в r-value). В противном случае, std::move_if_noexcept() вернет обычную l-value ссылку на объект.
Ключевой момент: Функция std::move_if_noexcept() вернет перемещаемый r-value объект в том случае, если объект имеет noexcept-конструктор перемещения, в противном случае он вернет копируемый l-value объект. Мы можем задействовать спецификатор noexcept в сочетании с std::move_if_noexcept() для использования семантики перемещения только тогда, когда выполняется строгая гарантия исключений (и использовать семантику копирования в противном случае).
Давайте обновим код из предыдущего примера следующим образом:
1 2 |
//std::pair moved_pair{std::move(my_pair)}; // закомментируем эту строку std::pair moved_pair{std::move_if_noexcept(my_pair)}; // и раскомментируем эту строку |
Запуск программы покажет нам следующий результат:
destroying MoveClass(empty)
my_pair.first: MoveClass(13)
destroying MoveClass(13)
Error found: abort!
my_pair.first: MoveClass(13)
destroying MoveClass(13)
Как вы можете видеть, после того, как было выброшено исключение, подобъект my_pair.first
по-прежнему указывает на значение 13
.
Класс CopyClass не имеет noexcept-конструктора перемещения, поэтому std::move_if_noexcept() возвращает my_pair
в качестве l-value ссылки. Это приводит к тому, что moved_pair
создается с помощью конструктора копирования (а не конструктора перемещения). Конструктор копирования может смело выбрасывать исключения, поскольку он не изменяет исходный объект.
Стандартная библиотека С++ часто использует std::move_if_noexcept() для оптимизации функций, которые являются noexcept. Например, std::vector::resize() будет использовать семантику перемещения, если тип элемента имеет noexcept-конструктор перемещения, и семантику копирования в противном случае. Это означает, что std::vector обычно будет работать быстрее с объектами, имеющими noexcept-конструктор перемещения (напоминание: конструкторы перемещения по умолчанию являются noexcept, если только они не вызывают функцию, которая является noexcept(false)
).
Предупреждение: Если тип имеет как потенциально вызывающую исключения семантику перемещения, так и семантику удаленного копирования (конструктор копирования и оператор присваивания копированием недоступны), то std::move_if_noexcept() откажется от строгой гарантии исключений и вызовет семантику перемещения. Этот условный отказ от строгой гарантии повсеместно встречается в стандартных библиотечных контейнерных классах, поскольку они часто используют std::move_if_noexcept().